Client.php 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. <?php
  2. /**
  3. * Copyright (C) 2014-2020 Textalk/Abicart and contributors.
  4. *
  5. * This file is part of Websocket PHP and is free software under the ISC License.
  6. * License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
  7. */
  8. namespace WebSocket;
  9. class Client extends Base
  10. {
  11. // Default options
  12. protected static $default_options = [
  13. 'persistent' => false,
  14. 'timeout' => 5,
  15. 'fragment_size' => 4096,
  16. 'context' => null,
  17. 'headers' => null,
  18. 'logger' => null,
  19. 'origin' => null, // @deprecated
  20. ];
  21. protected $socket_uri;
  22. /**
  23. * @param string $uri A ws/wss-URI
  24. * @param array $options
  25. * Associative array containing:
  26. * - context: Set the stream context. Default: empty context
  27. * - timeout: Set the socket timeout in seconds. Default: 5
  28. * - fragment_size: Set framgemnt size. Default: 4096
  29. * - headers: Associative array of headers to set/override.
  30. */
  31. public function __construct($uri, $options = array())
  32. {
  33. $this->options = array_merge(self::$default_options, $options);
  34. $this->socket_uri = $uri;
  35. $this->setLogger($this->options['logger']);
  36. }
  37. public function __destruct()
  38. {
  39. if ($this->isConnected()) {
  40. fclose($this->socket);
  41. }
  42. $this->socket = null;
  43. }
  44. /**
  45. * Perform WebSocket handshake
  46. */
  47. public function connect()
  48. {
  49. $url_parts = parse_url($this->socket_uri);
  50. if (empty($url_parts) || empty($url_parts['scheme']) || empty($url_parts['host'])) {
  51. $error = "Invalid url '{$this->socket_uri}' provided.";
  52. $this->logger->error($error);
  53. throw new BadUriException($error);
  54. }
  55. $scheme = $url_parts['scheme'];
  56. $host = $url_parts['host'];
  57. $user = isset($url_parts['user']) ? $url_parts['user'] : '';
  58. $pass = isset($url_parts['pass']) ? $url_parts['pass'] : '';
  59. $port = isset($url_parts['port']) ? $url_parts['port'] : ($scheme === 'wss' ? 443 : 80);
  60. $path = isset($url_parts['path']) ? $url_parts['path'] : '/';
  61. $query = isset($url_parts['query']) ? $url_parts['query'] : '';
  62. $fragment = isset($url_parts['fragment']) ? $url_parts['fragment'] : '';
  63. $path_with_query = $path;
  64. if (!empty($query)) {
  65. $path_with_query .= '?' . $query;
  66. }
  67. if (!empty($fragment)) {
  68. $path_with_query .= '#' . $fragment;
  69. }
  70. if (!in_array($scheme, array('ws', 'wss'))) {
  71. $error = "Url should have scheme ws or wss, not '{$scheme}' from URI '{$this->socket_uri}'.";
  72. $this->logger->error($error);
  73. throw new BadUriException($error);
  74. }
  75. $host_uri = ($scheme === 'wss' ? 'ssl' : 'tcp') . '://' . $host;
  76. // Set the stream context options if they're already set in the config
  77. if (isset($this->options['context'])) {
  78. // Suppress the error since we'll catch it below
  79. if (@get_resource_type($this->options['context']) === 'stream-context') {
  80. $context = $this->options['context'];
  81. } else {
  82. $error = "Stream context in \$options['context'] isn't a valid context.";
  83. $this->logger->error($error);
  84. throw new \InvalidArgumentException($error);
  85. }
  86. } else {
  87. $context = stream_context_create();
  88. }
  89. $flags = STREAM_CLIENT_CONNECT;
  90. $flags = ($this->options['persistent'] === true) ? $flags | STREAM_CLIENT_PERSISTENT : $flags;
  91. // Open the socket. @ is there to supress warning that we will catch in check below instead.
  92. $this->socket = @stream_socket_client(
  93. $host_uri . ':' . $port,
  94. $errno,
  95. $errstr,
  96. $this->options['timeout'],
  97. $flags,
  98. $context
  99. );
  100. if (!$this->isConnected()) {
  101. $error = "Could not open socket to \"{$host}:{$port}\": {$errstr} ({$errno}).";
  102. $this->logger->error($error);
  103. throw new ConnectionException($error);
  104. }
  105. // Set timeout on the stream as well.
  106. stream_set_timeout($this->socket, $this->options['timeout']);
  107. // Generate the WebSocket key.
  108. $key = self::generateKey();
  109. // Default headers
  110. $headers = array(
  111. 'Host' => $host . ":" . $port,
  112. 'User-Agent' => 'websocket-client-php',
  113. 'Connection' => 'Upgrade',
  114. 'Upgrade' => 'websocket',
  115. 'Sec-WebSocket-Key' => $key,
  116. 'Sec-WebSocket-Version' => '13',
  117. );
  118. // Handle basic authentication.
  119. if ($user || $pass) {
  120. $headers['authorization'] = 'Basic ' . base64_encode($user . ':' . $pass) . "\r\n";
  121. }
  122. // Deprecated way of adding origin (use headers instead).
  123. if (isset($this->options['origin'])) {
  124. $headers['origin'] = $this->options['origin'];
  125. }
  126. // Add and override with headers from options.
  127. if (isset($this->options['headers'])) {
  128. $headers = array_merge($headers, $this->options['headers']);
  129. }
  130. $header = "GET " . $path_with_query . " HTTP/1.1\r\n" . implode(
  131. "\r\n",
  132. array_map(
  133. function ($key, $value) {
  134. return "$key: $value";
  135. },
  136. array_keys($headers),
  137. $headers
  138. )
  139. ) . "\r\n\r\n";
  140. // Send headers.
  141. $this->write($header);
  142. // Get server response header (terminated with double CR+LF).
  143. $response = stream_get_line($this->socket, 1024, "\r\n\r\n");
  144. /// @todo Handle version switching
  145. $address = "{$scheme}://{$host}{$path_with_query}";
  146. // Validate response.
  147. if (!preg_match('#Sec-WebSocket-Accept:\s(.*)$#mUi', $response, $matches)) {
  148. $error = "Connection to '{$address}' failed: Server sent invalid upgrade response: {$response}";
  149. $this->logger->error($error);
  150. throw new ConnectionException($error);
  151. }
  152. $keyAccept = trim($matches[1]);
  153. $expectedResonse
  154. = base64_encode(pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));
  155. if ($keyAccept !== $expectedResonse) {
  156. $error = 'Server sent bad upgrade response.';
  157. $this->logger->error($error);
  158. throw new ConnectionException($error);
  159. }
  160. $this->logger->info("Client connected to to {$address}");
  161. }
  162. /**
  163. * Generate a random string for WebSocket key.
  164. *
  165. * @return string Random string
  166. */
  167. protected static function generateKey()
  168. {
  169. $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789';
  170. $key = '';
  171. $chars_length = strlen($chars);
  172. for ($i = 0; $i < 16; $i++) {
  173. $key .= $chars[mt_rand(0, $chars_length - 1)];
  174. }
  175. return base64_encode($key);
  176. }
  177. }