diff --git a/docs/Changelog.md b/docs/Changelog.md index c9e09e8..1c124ca 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -8,8 +8,14 @@ ### `2.2.0` - * Documentation review (@sirn-se) - * Dependency updates (@sirn-se) + * Optional `SubprotocolNegotiation` middleware (@sirn-se) + * `getMeta()` exposed on Client (@sirn-se) + * Server throws `HandshakeException` if HTTP middleware return invalid status (@sirn-se) + * Improved URI handling (@sirn-se) + * Allow empty HTTP header handling (RFC compliance) (@sirn-se) + * Documentation changes (@sirn-se) + * Using `phrity/net v2` (@sirn-se) + * Updating workflow and dependencies (@sirn-se) ## `v2.1` diff --git a/docs/Client.md b/docs/Client.md index c351705..8db3d62 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -187,6 +187,9 @@ echo "local: {$client->getName()}\n"; // View server name echo "remote: {$client->getRemoteName()}\n"; +// Get meta data by key +echo "meta: {$client->getMeta('some-metadata')}\n"; + // Get response on handshake $response = $client->getHandshakeResponse(); ``` diff --git a/docs/Contributing.md b/docs/Contributing.md index 68c85b5..2342a6c 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -27,7 +27,7 @@ Base your patch on corresponding version branch, and target that version branch | [`2.2`](https://github.com/sirn-se/websocket-php/tree/2.2.0) | `v2.2-main` | `^8.0` | Current version | | [`2.1`](https://github.com/sirn-se/websocket-php/tree/2.1.0) | `v2.1-main` | `^8.0` | Bug fixes only | | [`2.0`](https://github.com/sirn-se/websocket-php/tree/2.0.0) | - | `^8.0` | Not supported | -| [`1.7`](https://github.com/sirn-se/websocket-php/tree/1.7.0) | - | `^7.4\|^8.0` | Not supported | +| [`1.7`](https://github.com/sirn-se/websocket-php/tree/1.7.0) | `v1.7-master` | `^7.4\|^8.0` | Bug fixes only | | [`1.6`](https://github.com/sirn-se/websocket-php/tree/1.6.0) | - | `^7.4\|^8.0` | Not supported | | [`1.5`](https://github.com/sirn-se/websocket-php/tree/1.5.0) | - | `^7.4\|^8.0` | Not supported | | [`1.4`](https://github.com/sirn-se/websocket-php/tree/1.4.0) | - | `^7.1` | Not supported | diff --git a/docs/Middleware.md b/docs/Middleware.md index fc75444..8a712f8 100644 --- a/docs/Middleware.md +++ b/docs/Middleware.md @@ -33,6 +33,7 @@ These middlewares are included in library and can be added to provide additional * [PingInterval](Middleware/PingInterval.md) - Used to automatically send Ping messages at specified interval * [Callback](Middleware/Callback.md) - Apply provided callback function on specified actions +* [SubprotocolNegotiation](Middleware/SubprotocolNegotiation.md) - Helper middleware that negotiate subprotocol ## Creating your own middleware diff --git a/docs/Middleware/SubprotocolNegotiation.md b/docs/Middleware/SubprotocolNegotiation.md new file mode 100644 index 0000000..8eb2ec9 --- /dev/null +++ b/docs/Middleware/SubprotocolNegotiation.md @@ -0,0 +1,56 @@ +[Documentation](../Index.md) / [Middleware](../Middleware.md) / SubprotocolNegotiation + +# Websocket: SubprotocolNegotiation middleware + +This middlewares is included in library and can be added to provide additional functionality. + +It can be added to both Client and Server to help negotiate subprotocol to use. +Note: This Middleware only negotiate protocols, it does NOT implement any subprotocols. + +## Client + +When used on Client, it will send a list of requested subprotocols to the Server. +The Server is then expected to respond with the first requested subprotocol it supports, if any. +The Client MUST expect Server to send messages according to selected subprotocol. + +```php +$client->addMiddleware(new WebSocket\Middleware\SubprotocolNegotiation([ + 'subproto-1', + 'subproto-2', + 'subproto-3', +])); +$client->connect(); +$selected_subprotocol = $this->client->getMeta('subprotocolNegotiation.selected'); +``` + +## Server + +When added on Server, it should be defined with a list of subprotocols that Server support. +When Client request subprotocols, it will select the first requested protocol available in the list. +The ClienServert MUST expect Client to send messages according to selected subprotocol. + +```php +$server->addMiddleware(new WebSocket\Middleware\SubprotocolNegotiation([ + 'subproto-1', + 'subproto-2', + 'subproto-3', +])); +$server->->onText(function (WebSocket\Server $server, WebSocket\Connection $connection, WebSocket\Message\Message $message) { + $selected_subprotocol = $connection->getMeta('subprotocolNegotiation.selected'); +})->start(); +``` + +## Require option + +If second parameter is set to `true` a failed negotiation will close connection. + +* When used on Client, this will cause a `HandshakeException`. +* When used on Server, server will respond with `426 Upgrade Required` HTTP error status. + +```php +$client->addMiddleware(new WebSocket\Middleware\SubprotocolNegotiation([ + 'subproto-1', + 'subproto-2', + 'subproto-3', +], true)); +``` diff --git a/src/Client.php b/src/Client.php index aa1307f..998684a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -448,6 +448,16 @@ public function getRemoteName(): string|null return $this->isConnected() ? $this->connection->getRemoteName() : null; } + /** + * Get meta value on connection. + * @param string $key Meta key + * @return mixed Meta value + */ + public function getMeta(string $key): mixed + { + return $this->isConnected() ? $this->connection->getMeta($key) : null; + } + /** * Get Response for handshake procedure. * @return Response|null Handshake. @@ -488,10 +498,10 @@ protected function performHandshake(Uri $uri): Response $request = $request->withHeader($name, $content); } - $this->connection->pushHttp($request); - $response = $this->connection->pullHttp(); - try { + $request = $this->connection->pushHttp($request); + $response = $this->connection->pullHttp(); + if ($response->getStatusCode() != 101) { throw new HandshakeException("Invalid status code {$response->getStatusCode()}.", $response); } diff --git a/src/Connection.php b/src/Connection.php index 61525c3..15d889d 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -256,7 +256,7 @@ public function setMeta(string $key, mixed $value): void } /** - * Set meta value on connection. + * Get meta value on connection. * @param string $key Meta key * @return mixed Meta value */ diff --git a/src/Middleware/SubprotocolNegotiation.php b/src/Middleware/SubprotocolNegotiation.php new file mode 100644 index 0000000..d7a822f --- /dev/null +++ b/src/Middleware/SubprotocolNegotiation.php @@ -0,0 +1,99 @@ +subprotocols = $subprotocols; + $this->require = $require; + } + + public function processHttpOutgoing(ProcessHttpStack $stack, Connection $connection, Message $message): Message + { + if ($message instanceof Request) { + // Outgoing requests on Client + foreach ($this->subprotocols as $subprotocol) { + $message = $message->withAddedHeader('Sec-WebSocket-Protocol', $subprotocol); + } + if ($supported = implode(', ', $this->subprotocols)) { + $this->logger->debug("[subprotocol-negotiation] Requested subprotocols: {$supported}"); + } + } elseif ($message instanceof Response) { + // Outgoing Response on Server + if ($selected = $connection->getMeta('subprotocolNegotiation.selected')) { + $message = $message->withHeader('Sec-WebSocket-Protocol', $selected); + $this->logger->info("[subprotocol-negotiation] Selected subprotocol: {$selected}"); + } elseif ($this->require) { + // No matching subprotocol, fail handshake + $message = $message->withStatus(426); + } + } + return $stack->handleHttpOutgoing($message); + } + + public function processHttpIncoming(ProcessHttpStack $stack, Connection $connection): Message + { + $connection->setMeta('subprotocolNegotiation.selected', null); + $message = $stack->handleHttpIncoming(); + + if ($message instanceof ServerRequest) { + // Incoming requests on Server + if ($requested = $message->getHeaderLine('Sec-WebSocket-Protocol')) { + $this->logger->debug("[subprotocol-negotiation] Requested subprotocols: {$requested}"); + } + if ($supported = implode(', ', $this->subprotocols)) { + $this->logger->debug("[subprotocol-negotiation] Supported subprotocols: {$supported}"); + } + foreach ($message->getHeader('Sec-WebSocket-Protocol') as $subprotocol) { + if (in_array($subprotocol, $this->subprotocols)) { + $connection->setMeta('subprotocolNegotiation.selected', $subprotocol); + return $message; + } + } + } elseif ($message instanceof Response) { + // Incoming Response on Client + if ($selected = $message->getHeaderLine('Sec-WebSocket-Protocol')) { + $connection->setMeta('subprotocolNegotiation.selected', $selected); + $this->logger->info("[subprotocol-negotiation] Selected subprotocol: {$selected}"); + } elseif ($this->require) { + // No matching subprotocol, close and fail + $connection->close(); + throw new HandshakeException("Could not resolve subprotocol.", $message); + } + } + return $message; + } +} diff --git a/src/Server.php b/src/Server.php index 94f2c38..b6553cd 100644 --- a/src/Server.php +++ b/src/Server.php @@ -480,7 +480,11 @@ protected function performHandshake(Connection $connection): ServerRequest } // Respond to handshake - $connection->pushHttp($response); + $response = $connection->pushHttp($response); + if ($response->getStatusCode() != 101) { + $exception = new HandshakeException("Invalid status code {$response->getStatusCode()}", $response); + } + if ($exception) { throw $exception; } diff --git a/tests/suites/client/ClientTest.php b/tests/suites/client/ClientTest.php index 0968651..89401b1 100644 --- a/tests/suites/client/ClientTest.php +++ b/tests/suites/client/ClientTest.php @@ -736,6 +736,7 @@ public function testConvenicanceMethods(): void $this->assertNull($client->getName()); $this->assertNull($client->getRemoteName()); + $this->assertNull($client->getMeta('metadata')); $this->assertEquals('WebSocket\Client(closed)', "{$client}"); $this->expectWsClientConnect(); diff --git a/tests/suites/middleware/CallbackTest.php b/tests/suites/middleware/CallbackTest.php index 75d9d56..3987304 100644 --- a/tests/suites/middleware/CallbackTest.php +++ b/tests/suites/middleware/CallbackTest.php @@ -15,6 +15,7 @@ use Psr\Log\NullLogger; use Stringable; use WebSocket\Connection; +use WebSocket\Http\Response; use WebSocket\Message\Text; use WebSocket\Middleware\Callback; @@ -148,7 +149,7 @@ public function testHttpOutgoing(): void return $message; })); $this->expectSocketStreamWrite(); - $message = $connection->pushHttp(new \WebSocket\Http\Response(200)); + $message = $connection->pushHttp(new Response(200)); $this->assertEquals(400, $message->getStatusCode()); $this->expectSocketStreamIsConnected(); diff --git a/tests/suites/middleware/SubprotocolNegotiationTest.php b/tests/suites/middleware/SubprotocolNegotiationTest.php new file mode 100644 index 0000000..89f18c4 --- /dev/null +++ b/tests/suites/middleware/SubprotocolNegotiationTest.php @@ -0,0 +1,310 @@ +setUpStack(); + } + + public function tearDown(): void + { + $this->tearDownStack(); + } + + public function testClientProtocolMatch(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3']); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-1\r\n" + . "Sec-WebSocket-Protocol: sp-2\r\n" + . "Sec-WebSocket-Protocol: sp-3\r\n\r\n", + $params[0] + ); + } + ); + $request = new Request('GET', 'ws://test.url'); + $request = $connection->pushHttp($request); + $this->assertEquals(['sp-1', 'sp-2', 'sp-3'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "HTTP/1.1 200 OK\r\nSec-WebSocket-Protocol: sp-2\r\n\r\n"; + }); + $response = $connection->pullHttp(); + $this->assertEquals(['sp-2'], $response->getHeader('Sec-WebSocket-Protocol')); + $this->assertEquals('sp-2', $connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + unset($connection); + } + + public function testClientProtocolNoMatch(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3']); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-1\r\n" + . "Sec-WebSocket-Protocol: sp-2\r\n" + . "Sec-WebSocket-Protocol: sp-3\r\n\r\n", + $params[0] + ); + } + ); + $request = new Request('GET', 'ws://test.url'); + $request = $connection->pushHttp($request); + $this->assertEquals(['sp-1', 'sp-2', 'sp-3'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "HTTP/1.1 200 OK\r\n\r\n"; + }); + $response = $connection->pullHttp(); + $this->assertEquals([], $response->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + unset($connection); + } + + public function testClientProtocolRequire(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3'], true); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-1\r\n" + . "Sec-WebSocket-Protocol: sp-2\r\n" + . "Sec-WebSocket-Protocol: sp-3\r\n\r\n", + $params[0] + ); + } + ); + $request = new Request('GET', 'ws://test.url'); + $request = $connection->pushHttp($request); + $this->assertEquals(['sp-1', 'sp-2', 'sp-3'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "HTTP/1.1 200 OK\r\n\r\n"; + }); + $this->expectSocketStreamWrite(); + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + $this->expectException(HandshakeException::class); + $this->expectExceptionMessage('Could not resolve subprotocol.'); + $connection->pullHttp(); + } + + public function testServerProtocolMatch(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3']); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-11\r\n" + . "Sec-WebSocket-Protocol: sp-2\r\n" + . "Sec-WebSocket-Protocol: sp-33\r\n\r\n"; + }); + $request = $connection->pullHttp(); + $this->assertEquals(['sp-11', 'sp-2', 'sp-33'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertEquals('sp-2', $connection->getMeta('subprotocolNegotiation.selected')); + + $response = new Response(200); + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "HTTP/1.1 200 OK\r\nSec-WebSocket-Protocol: sp-2\r\n\r\n", + $params[0] + ); + } + ); + $response = $connection->pushHttp($response); + $this->assertEquals(['sp-2'], $response->getHeader('Sec-WebSocket-Protocol')); + $this->assertEquals('sp-2', $connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + unset($connection); + } + + public function testServerProtocolNoMatch(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3']); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-11\r\n" + . "Sec-WebSocket-Protocol: sp-22\r\n" + . "Sec-WebSocket-Protocol: sp-33\r\n\r\n"; + }); + $request = $connection->pullHttp(); + $this->assertEquals(['sp-11', 'sp-22', 'sp-33'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $response = new Response(200); + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "HTTP/1.1 200 OK\r\n\r\n", + $params[0] + ); + } + ); + $response = $connection->pushHttp($response); + $this->assertEquals([], $response->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + unset($connection); + } + + public function testServerProtocolRequire(): void + { + $temp = tmpfile(); + + $middleware = new SubprotocolNegotiation(['sp-1', 'sp-2', 'sp-3'], true); + $this->assertEquals('WebSocket\Middleware\SubprotocolNegotiation', "{$middleware}"); + $this->assertInstanceOf(Stringable::class, $middleware); + + $this->expectSocketStream(); + $this->expectSocketStreamGetMetadata(); + $stream = new SocketStream($temp); + + $this->expectSocketStreamGetLocalName(); + $this->expectSocketStreamGetRemoteName(); + $connection = new Connection($stream, false, false); + $connection->addMiddleware($middleware); + + $this->expectSocketStreamReadLine()->setReturn(function () { + return "GET / HTTP/1.1\r\nHost: test.url\r\n" + . "Sec-WebSocket-Protocol: sp-11\r\n" + . "Sec-WebSocket-Protocol: sp-22\r\n" + . "Sec-WebSocket-Protocol: sp-33\r\n\r\n"; + }); + $request = $connection->pullHttp(); + $this->assertEquals(['sp-11', 'sp-22', 'sp-33'], $request->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $response = new Response(200); + $this->expectSocketStreamWrite()->addAssert( + function (string $method, array $params): void { + $this->assertEquals( + "HTTP/1.1 426 Upgrade Required\r\n\r\n", + $params[0] + ); + } + ); + $response = $connection->pushHttp($response); + $this->assertEquals([], $response->getHeader('Sec-WebSocket-Protocol')); + $this->assertNull($connection->getMeta('subprotocolNegotiation.selected')); + + $this->expectSocketStreamIsConnected(); + $this->expectSocketStreamClose(); + unset($connection); + } +}