123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490 |
- <?php
- /**
- * Copyright (C) 2014-2022 Textalk/Abicart and contributors.
- *
- * This file is part of Websocket PHP and is free software under the ISC License.
- * License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
- */
- namespace WebSocket;
- use ErrorException;
- use InvalidArgumentException;
- use Phrity\Net\Uri;
- use Phrity\Util\ErrorHandler;
- use Psr\Http\Message\UriInterface;
- use Psr\Log\{
- LoggerAwareInterface,
- LoggerAwareTrait,
- LoggerInterface,
- NullLogger
- };
- use WebSocket\Message\Factory;
- class Client implements LoggerAwareInterface
- {
- use LoggerAwareTrait; // provides setLogger(LoggerInterface $logger)
- use OpcodeTrait;
- // Default options
- protected static $default_options = [
- 'context' => null,
- 'filter' => ['text', 'binary'],
- 'fragment_size' => 4096,
- 'headers' => null,
- 'logger' => null,
- 'origin' => null, // @deprecated
- 'persistent' => false,
- 'return_obj' => false,
- 'timeout' => 5,
- ];
- private $socket_uri;
- private $connection;
- private $options = [];
- private $listen = false;
- private $last_opcode = null;
- /* ---------- Magic methods ------------------------------------------------------ */
- /**
- * @param UriInterface|string $uri A ws/wss-URI
- * @param array $options
- * Associative array containing:
- * - context: Set the stream context. Default: empty context
- * - timeout: Set the socket timeout in seconds. Default: 5
- * - fragment_size: Set framgemnt size. Default: 4096
- * - headers: Associative array of headers to set/override.
- */
- public function __construct($uri, array $options = [])
- {
- $this->socket_uri = $this->parseUri($uri);
- $this->options = array_merge(self::$default_options, [
- 'logger' => new NullLogger(),
- ], $options);
- $this->setLogger($this->options['logger']);
- }
- /**
- * Get string representation of instance.
- * @return string String representation.
- */
- public function __toString(): string
- {
- return sprintf(
- "%s(%s)",
- get_class($this),
- $this->getName() ?: 'closed'
- );
- }
- /* ---------- Client option functions -------------------------------------------- */
- /**
- * Set timeout.
- * @param int $timeout Timeout in seconds.
- */
- public function setTimeout(int $timeout): void
- {
- $this->options['timeout'] = $timeout;
- if (!$this->isConnected()) {
- return;
- }
- $this->connection->setTimeout($timeout);
- $this->connection->setOptions($this->options);
- }
- /**
- * Set fragmentation size.
- * @param int $fragment_size Fragment size in bytes.
- * @return self.
- */
- public function setFragmentSize(int $fragment_size): self
- {
- $this->options['fragment_size'] = $fragment_size;
- $this->connection->setOptions($this->options);
- return $this;
- }
- /**
- * Get fragmentation size.
- * @return int $fragment_size Fragment size in bytes.
- */
- public function getFragmentSize(): int
- {
- return $this->options['fragment_size'];
- }
- /* ---------- Connection operations ---------------------------------------------- */
- /**
- * Send text message.
- * @param string $payload Content as string.
- */
- public function text(string $payload): void
- {
- $this->send($payload);
- }
- /**
- * Send binary message.
- * @param string $payload Content as binary string.
- */
- public function binary(string $payload): void
- {
- $this->send($payload, 'binary');
- }
- /**
- * Send ping.
- * @param string $payload Optional text as string.
- */
- public function ping(string $payload = ''): void
- {
- $this->send($payload, 'ping');
- }
- /**
- * Send unsolicited pong.
- * @param string $payload Optional text as string.
- */
- public function pong(string $payload = ''): void
- {
- $this->send($payload, 'pong');
- }
- /**
- * Send message.
- * @param string $payload Message to send.
- * @param string $opcode Opcode to use, default: 'text'.
- * @param bool $masked If message should be masked default: true.
- */
- public function send(string $payload, string $opcode = 'text', bool $masked = true): void
- {
- if (!$this->isConnected()) {
- $this->connect();
- }
- if (!in_array($opcode, array_keys(self::$opcodes))) {
- $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'.";
- $this->logger->warning($warning);
- throw new BadOpcodeException($warning);
- }
- $factory = new Factory();
- $message = $factory->create($opcode, $payload);
- $this->connection->pushMessage($message, $masked);
- }
- /**
- * Tell the socket to close.
- * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
- * @param string $message A closing message, max 125 bytes.
- */
- public function close(int $status = 1000, string $message = 'ttfn'): void
- {
- if (!$this->isConnected()) {
- return;
- }
- $this->connection->close($status, $message);
- }
- /**
- * Disconnect from server.
- */
- public function disconnect(): void
- {
- if ($this->isConnected()) {
- $this->connection->disconnect();
- }
- }
- /**
- * Receive message.
- * Note that this operation will block reading.
- * @return mixed Message, text or null depending on settings.
- */
- public function receive()
- {
- $filter = $this->options['filter'];
- $return_obj = $this->options['return_obj'];
- if (!$this->isConnected()) {
- $this->connect();
- }
- while (true) {
- $message = $this->connection->pullMessage();
- $opcode = $message->getOpcode();
- if (in_array($opcode, $filter)) {
- $this->last_opcode = $opcode;
- $return = $return_obj ? $message : $message->getContent();
- break;
- } elseif ($opcode == 'close') {
- $this->last_opcode = null;
- $return = $return_obj ? $message : null;
- break;
- }
- }
- return $return;
- }
- /* ---------- Connection functions ----------------------------------------------- */
- /**
- * Get last received opcode.
- * @return string|null Opcode.
- */
- public function getLastOpcode(): ?string
- {
- return $this->last_opcode;
- }
- /**
- * Get close status on connection.
- * @return int|null Close status.
- */
- public function getCloseStatus(): ?int
- {
- return $this->connection ? $this->connection->getCloseStatus() : null;
- }
- /**
- * If Client has active connection.
- * @return bool True if active connection.
- */
- public function isConnected(): bool
- {
- return $this->connection && $this->connection->isConnected();
- }
- /**
- * Get name of local socket, or null if not connected.
- * @return string|null
- */
- public function getName(): ?string
- {
- return $this->isConnected() ? $this->connection->getName() : null;
- }
- /**
- * Get name of remote socket, or null if not connected.
- * @return string|null
- */
- public function getRemoteName(): ?string
- {
- return $this->isConnected() ? $this->connection->getRemoteName() : null;
- }
- /**
- * Get name of remote socket, or null if not connected.
- * @return string|null
- * @deprecated Will be removed in future version, use getPeer() instead.
- */
- public function getPier(): ?string
- {
- trigger_error(
- 'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.',
- E_USER_DEPRECATED
- );
- return $this->getRemoteName();
- }
- /* ---------- Helper functions --------------------------------------------------- */
- /**
- * Perform WebSocket handshake
- */
- protected function connect(): void
- {
- $this->connection = null;
- $host_uri = $this->socket_uri
- ->withScheme($this->socket_uri->getScheme() == 'wss' ? 'ssl' : 'tcp')
- ->withPort($this->socket_uri->getPort() ?? ($this->socket_uri->getScheme() == 'wss' ? 443 : 80))
- ->withPath('')
- ->withQuery('')
- ->withFragment('')
- ->withUserInfo('');
- // Path must be absolute
- $http_path = $this->socket_uri->getPath();
- if ($http_path === '' || $http_path[0] !== '/') {
- $http_path = "/{$http_path}";
- }
- $http_uri = (new Uri())
- ->withPath($http_path)
- ->withQuery($this->socket_uri->getQuery());
- // Set the stream context options if they're already set in the config
- if (isset($this->options['context'])) {
- // Suppress the error since we'll catch it below
- if (@get_resource_type($this->options['context']) === 'stream-context') {
- $context = $this->options['context'];
- } else {
- $error = "Stream context in \$options['context'] isn't a valid context.";
- $this->logger->error($error);
- throw new \InvalidArgumentException($error);
- }
- } else {
- $context = stream_context_create();
- }
- $persistent = $this->options['persistent'] === true;
- $flags = STREAM_CLIENT_CONNECT;
- $flags = $persistent ? $flags | STREAM_CLIENT_PERSISTENT : $flags;
- $socket = null;
- try {
- $handler = new ErrorHandler();
- $socket = $handler->with(function () use ($host_uri, $flags, $context) {
- $error = $errno = $errstr = null;
- // Open the socket.
- return stream_socket_client(
- $host_uri,
- $errno,
- $errstr,
- $this->options['timeout'],
- $flags,
- $context
- );
- });
- if (!$socket) {
- throw new ErrorException('No socket');
- }
- } catch (ErrorException $e) {
- $error = "Could not open socket to \"{$host_uri->getAuthority()}\": {$e->getMessage()} ({$e->getCode()}).";
- $this->logger->error($error, ['severity' => $e->getSeverity()]);
- throw new ConnectionException($error, 0, [], $e);
- }
- $this->connection = new Connection($socket, $this->options);
- $this->connection->setLogger($this->logger);
- if (!$this->isConnected()) {
- $error = "Invalid stream on \"{$host_uri->getAuthority()}\".";
- $this->logger->error($error);
- throw new ConnectionException($error);
- }
- if (!$persistent || $this->connection->tell() == 0) {
- // Set timeout on the stream as well.
- $this->connection->setTimeout($this->options['timeout']);
- // Generate the WebSocket key.
- $key = self::generateKey();
- // Default headers
- $headers = [
- 'Host' => $host_uri->getAuthority(),
- 'User-Agent' => 'websocket-client-php',
- 'Connection' => 'Upgrade',
- 'Upgrade' => 'websocket',
- 'Sec-WebSocket-Key' => $key,
- 'Sec-WebSocket-Version' => '13',
- ];
- // Handle basic authentication.
- if ($userinfo = $this->socket_uri->getUserInfo()) {
- $headers['authorization'] = 'Basic ' . base64_encode($userinfo);
- }
- // Deprecated way of adding origin (use headers instead).
- if (isset($this->options['origin'])) {
- $headers['origin'] = $this->options['origin'];
- }
- // Add and override with headers from options.
- if (isset($this->options['headers'])) {
- $headers = array_merge($headers, $this->options['headers']);
- }
- $header = "GET {$http_uri} HTTP/1.1\r\n" . implode(
- "\r\n",
- array_map(
- function ($key, $value) {
- return "$key: $value";
- },
- array_keys($headers),
- $headers
- )
- ) . "\r\n\r\n";
- // Send headers.
- $this->connection->write($header);
- // Get server response header (terminated with double CR+LF).
- $response = '';
- try {
- do {
- $buffer = $this->connection->gets(1024);
- $response .= $buffer;
- } while (substr_count($response, "\r\n\r\n") == 0);
- } catch (Exception $e) {
- throw new ConnectionException('Client handshake error', $e->getCode(), $e->getData(), $e);
- }
- // Validate response.
- if (!preg_match('#Sec-WebSocket-Accept:\s(.*)$#mUi', $response, $matches)) {
- $error = sprintf(
- "Connection to '%s' failed: Server sent invalid upgrade response: %s",
- (string)$this->socket_uri,
- (string)$response
- );
- $this->logger->error($error);
- throw new ConnectionException($error);
- }
- $keyAccept = trim($matches[1]);
- $expectedResonse = base64_encode(
- pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))
- );
- if ($keyAccept !== $expectedResonse) {
- $error = 'Server sent bad upgrade response.';
- $this->logger->error($error);
- throw new ConnectionException($error);
- }
- }
- $this->logger->info("Client connected to {$this->socket_uri}");
- }
- /**
- * Generate a random string for WebSocket key.
- * @return string Random string
- */
- protected static function generateKey(): string
- {
- $key = '';
- for ($i = 0; $i < 16; $i++) {
- $key .= chr(rand(33, 126));
- }
- return base64_encode($key);
- }
- protected function parseUri($uri): UriInterface
- {
- if ($uri instanceof UriInterface) {
- $uri = $uri;
- } elseif (is_string($uri)) {
- try {
- $uri = new Uri($uri);
- } catch (InvalidArgumentException $e) {
- throw new BadUriException("Invalid URI '{$uri}' provided.", 0, $e);
- }
- } else {
- throw new BadUriException("Provided URI must be a UriInterface or string.");
- }
- if (!in_array($uri->getScheme(), ['ws', 'wss'])) {
- throw new BadUriException("Invalid URI scheme, must be 'ws' or 'wss'.");
- }
- return $uri;
- }
- }
|