Client.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. <?php
  2. /**
  3. * Copyright (C) 2014-2022 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. use ErrorException;
  10. use InvalidArgumentException;
  11. use Phrity\Net\Uri;
  12. use Phrity\Util\ErrorHandler;
  13. use Psr\Http\Message\UriInterface;
  14. use Psr\Log\{
  15. LoggerAwareInterface,
  16. LoggerAwareTrait,
  17. LoggerInterface,
  18. NullLogger
  19. };
  20. use WebSocket\Message\Factory;
  21. class Client implements LoggerAwareInterface
  22. {
  23. use LoggerAwareTrait; // provides setLogger(LoggerInterface $logger)
  24. use OpcodeTrait;
  25. // Default options
  26. protected static $default_options = [
  27. 'context' => null,
  28. 'filter' => ['text', 'binary'],
  29. 'fragment_size' => 4096,
  30. 'headers' => null,
  31. 'logger' => null,
  32. 'origin' => null, // @deprecated
  33. 'persistent' => false,
  34. 'return_obj' => false,
  35. 'timeout' => 5,
  36. ];
  37. private $socket_uri;
  38. private $connection;
  39. private $options = [];
  40. private $listen = false;
  41. private $last_opcode = null;
  42. /* ---------- Magic methods ------------------------------------------------------ */
  43. /**
  44. * @param UriInterface|string $uri A ws/wss-URI
  45. * @param array $options
  46. * Associative array containing:
  47. * - context: Set the stream context. Default: empty context
  48. * - timeout: Set the socket timeout in seconds. Default: 5
  49. * - fragment_size: Set framgemnt size. Default: 4096
  50. * - headers: Associative array of headers to set/override.
  51. */
  52. public function __construct($uri, array $options = [])
  53. {
  54. $this->socket_uri = $this->parseUri($uri);
  55. $this->options = array_merge(self::$default_options, [
  56. 'logger' => new NullLogger(),
  57. ], $options);
  58. $this->setLogger($this->options['logger']);
  59. }
  60. /**
  61. * Get string representation of instance.
  62. * @return string String representation.
  63. */
  64. public function __toString(): string
  65. {
  66. return sprintf(
  67. "%s(%s)",
  68. get_class($this),
  69. $this->getName() ?: 'closed'
  70. );
  71. }
  72. /* ---------- Client option functions -------------------------------------------- */
  73. /**
  74. * Set timeout.
  75. * @param int $timeout Timeout in seconds.
  76. */
  77. public function setTimeout(int $timeout): void
  78. {
  79. $this->options['timeout'] = $timeout;
  80. if (!$this->isConnected()) {
  81. return;
  82. }
  83. $this->connection->setTimeout($timeout);
  84. $this->connection->setOptions($this->options);
  85. }
  86. /**
  87. * Set fragmentation size.
  88. * @param int $fragment_size Fragment size in bytes.
  89. * @return self.
  90. */
  91. public function setFragmentSize(int $fragment_size): self
  92. {
  93. $this->options['fragment_size'] = $fragment_size;
  94. $this->connection->setOptions($this->options);
  95. return $this;
  96. }
  97. /**
  98. * Get fragmentation size.
  99. * @return int $fragment_size Fragment size in bytes.
  100. */
  101. public function getFragmentSize(): int
  102. {
  103. return $this->options['fragment_size'];
  104. }
  105. /* ---------- Connection operations ---------------------------------------------- */
  106. /**
  107. * Send text message.
  108. * @param string $payload Content as string.
  109. */
  110. public function text(string $payload): void
  111. {
  112. $this->send($payload);
  113. }
  114. /**
  115. * Send binary message.
  116. * @param string $payload Content as binary string.
  117. */
  118. public function binary(string $payload): void
  119. {
  120. $this->send($payload, 'binary');
  121. }
  122. /**
  123. * Send ping.
  124. * @param string $payload Optional text as string.
  125. */
  126. public function ping(string $payload = ''): void
  127. {
  128. $this->send($payload, 'ping');
  129. }
  130. /**
  131. * Send unsolicited pong.
  132. * @param string $payload Optional text as string.
  133. */
  134. public function pong(string $payload = ''): void
  135. {
  136. $this->send($payload, 'pong');
  137. }
  138. /**
  139. * Send message.
  140. * @param string $payload Message to send.
  141. * @param string $opcode Opcode to use, default: 'text'.
  142. * @param bool $masked If message should be masked default: true.
  143. */
  144. public function send(string $payload, string $opcode = 'text', bool $masked = true): void
  145. {
  146. if (!$this->isConnected()) {
  147. $this->connect();
  148. }
  149. if (!in_array($opcode, array_keys(self::$opcodes))) {
  150. $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'.";
  151. $this->logger->warning($warning);
  152. throw new BadOpcodeException($warning);
  153. }
  154. $factory = new Factory();
  155. $message = $factory->create($opcode, $payload);
  156. $this->connection->pushMessage($message, $masked);
  157. }
  158. /**
  159. * Tell the socket to close.
  160. * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
  161. * @param string $message A closing message, max 125 bytes.
  162. */
  163. public function close(int $status = 1000, string $message = 'ttfn'): void
  164. {
  165. if (!$this->isConnected()) {
  166. return;
  167. }
  168. $this->connection->close($status, $message);
  169. }
  170. /**
  171. * Disconnect from server.
  172. */
  173. public function disconnect(): void
  174. {
  175. if ($this->isConnected()) {
  176. $this->connection->disconnect();
  177. }
  178. }
  179. /**
  180. * Receive message.
  181. * Note that this operation will block reading.
  182. * @return mixed Message, text or null depending on settings.
  183. */
  184. public function receive()
  185. {
  186. $filter = $this->options['filter'];
  187. $return_obj = $this->options['return_obj'];
  188. if (!$this->isConnected()) {
  189. $this->connect();
  190. }
  191. while (true) {
  192. $message = $this->connection->pullMessage();
  193. $opcode = $message->getOpcode();
  194. if (in_array($opcode, $filter)) {
  195. $this->last_opcode = $opcode;
  196. $return = $return_obj ? $message : $message->getContent();
  197. break;
  198. } elseif ($opcode == 'close') {
  199. $this->last_opcode = null;
  200. $return = $return_obj ? $message : null;
  201. break;
  202. }
  203. }
  204. return $return;
  205. }
  206. /* ---------- Connection functions ----------------------------------------------- */
  207. /**
  208. * Get last received opcode.
  209. * @return string|null Opcode.
  210. */
  211. public function getLastOpcode(): ?string
  212. {
  213. return $this->last_opcode;
  214. }
  215. /**
  216. * Get close status on connection.
  217. * @return int|null Close status.
  218. */
  219. public function getCloseStatus(): ?int
  220. {
  221. return $this->connection ? $this->connection->getCloseStatus() : null;
  222. }
  223. /**
  224. * If Client has active connection.
  225. * @return bool True if active connection.
  226. */
  227. public function isConnected(): bool
  228. {
  229. return $this->connection && $this->connection->isConnected();
  230. }
  231. /**
  232. * Get name of local socket, or null if not connected.
  233. * @return string|null
  234. */
  235. public function getName(): ?string
  236. {
  237. return $this->isConnected() ? $this->connection->getName() : null;
  238. }
  239. /**
  240. * Get name of remote socket, or null if not connected.
  241. * @return string|null
  242. */
  243. public function getRemoteName(): ?string
  244. {
  245. return $this->isConnected() ? $this->connection->getRemoteName() : null;
  246. }
  247. /**
  248. * Get name of remote socket, or null if not connected.
  249. * @return string|null
  250. * @deprecated Will be removed in future version, use getPeer() instead.
  251. */
  252. public function getPier(): ?string
  253. {
  254. trigger_error(
  255. 'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.',
  256. E_USER_DEPRECATED
  257. );
  258. return $this->getRemoteName();
  259. }
  260. /* ---------- Helper functions --------------------------------------------------- */
  261. /**
  262. * Perform WebSocket handshake
  263. */
  264. protected function connect(): void
  265. {
  266. $this->connection = null;
  267. $host_uri = $this->socket_uri
  268. ->withScheme($this->socket_uri->getScheme() == 'wss' ? 'ssl' : 'tcp')
  269. ->withPort($this->socket_uri->getPort() ?? ($this->socket_uri->getScheme() == 'wss' ? 443 : 80))
  270. ->withPath('')
  271. ->withQuery('')
  272. ->withFragment('')
  273. ->withUserInfo('');
  274. // Path must be absolute
  275. $http_path = $this->socket_uri->getPath();
  276. if ($http_path === '' || $http_path[0] !== '/') {
  277. $http_path = "/{$http_path}";
  278. }
  279. $http_uri = (new Uri())
  280. ->withPath($http_path)
  281. ->withQuery($this->socket_uri->getQuery());
  282. // Set the stream context options if they're already set in the config
  283. if (isset($this->options['context'])) {
  284. // Suppress the error since we'll catch it below
  285. if (@get_resource_type($this->options['context']) === 'stream-context') {
  286. $context = $this->options['context'];
  287. } else {
  288. $error = "Stream context in \$options['context'] isn't a valid context.";
  289. $this->logger->error($error);
  290. throw new \InvalidArgumentException($error);
  291. }
  292. } else {
  293. $context = stream_context_create();
  294. }
  295. $persistent = $this->options['persistent'] === true;
  296. $flags = STREAM_CLIENT_CONNECT;
  297. $flags = $persistent ? $flags | STREAM_CLIENT_PERSISTENT : $flags;
  298. $socket = null;
  299. try {
  300. $handler = new ErrorHandler();
  301. $socket = $handler->with(function () use ($host_uri, $flags, $context) {
  302. $error = $errno = $errstr = null;
  303. // Open the socket.
  304. return stream_socket_client(
  305. $host_uri,
  306. $errno,
  307. $errstr,
  308. $this->options['timeout'],
  309. $flags,
  310. $context
  311. );
  312. });
  313. if (!$socket) {
  314. throw new ErrorException('No socket');
  315. }
  316. } catch (ErrorException $e) {
  317. $error = "Could not open socket to \"{$host_uri->getAuthority()}\": {$e->getMessage()} ({$e->getCode()}).";
  318. $this->logger->error($error, ['severity' => $e->getSeverity()]);
  319. throw new ConnectionException($error, 0, [], $e);
  320. }
  321. $this->connection = new Connection($socket, $this->options);
  322. $this->connection->setLogger($this->logger);
  323. if (!$this->isConnected()) {
  324. $error = "Invalid stream on \"{$host_uri->getAuthority()}\".";
  325. $this->logger->error($error);
  326. throw new ConnectionException($error);
  327. }
  328. if (!$persistent || $this->connection->tell() == 0) {
  329. // Set timeout on the stream as well.
  330. $this->connection->setTimeout($this->options['timeout']);
  331. // Generate the WebSocket key.
  332. $key = self::generateKey();
  333. // Default headers
  334. $headers = [
  335. 'Host' => $host_uri->getAuthority(),
  336. 'User-Agent' => 'websocket-client-php',
  337. 'Connection' => 'Upgrade',
  338. 'Upgrade' => 'websocket',
  339. 'Sec-WebSocket-Key' => $key,
  340. 'Sec-WebSocket-Version' => '13',
  341. ];
  342. // Handle basic authentication.
  343. if ($userinfo = $this->socket_uri->getUserInfo()) {
  344. $headers['authorization'] = 'Basic ' . base64_encode($userinfo);
  345. }
  346. // Deprecated way of adding origin (use headers instead).
  347. if (isset($this->options['origin'])) {
  348. $headers['origin'] = $this->options['origin'];
  349. }
  350. // Add and override with headers from options.
  351. if (isset($this->options['headers'])) {
  352. $headers = array_merge($headers, $this->options['headers']);
  353. }
  354. $header = "GET {$http_uri} HTTP/1.1\r\n" . implode(
  355. "\r\n",
  356. array_map(
  357. function ($key, $value) {
  358. return "$key: $value";
  359. },
  360. array_keys($headers),
  361. $headers
  362. )
  363. ) . "\r\n\r\n";
  364. // Send headers.
  365. $this->connection->write($header);
  366. // Get server response header (terminated with double CR+LF).
  367. $response = '';
  368. try {
  369. do {
  370. $buffer = $this->connection->gets(1024);
  371. $response .= $buffer;
  372. } while (substr_count($response, "\r\n\r\n") == 0);
  373. } catch (Exception $e) {
  374. throw new ConnectionException('Client handshake error', $e->getCode(), $e->getData(), $e);
  375. }
  376. // Validate response.
  377. if (!preg_match('#Sec-WebSocket-Accept:\s(.*)$#mUi', $response, $matches)) {
  378. $error = sprintf(
  379. "Connection to '%s' failed: Server sent invalid upgrade response: %s",
  380. (string)$this->socket_uri,
  381. (string)$response
  382. );
  383. $this->logger->error($error);
  384. throw new ConnectionException($error);
  385. }
  386. $keyAccept = trim($matches[1]);
  387. $expectedResonse = base64_encode(
  388. pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))
  389. );
  390. if ($keyAccept !== $expectedResonse) {
  391. $error = 'Server sent bad upgrade response.';
  392. $this->logger->error($error);
  393. throw new ConnectionException($error);
  394. }
  395. }
  396. $this->logger->info("Client connected to {$this->socket_uri}");
  397. }
  398. /**
  399. * Generate a random string for WebSocket key.
  400. * @return string Random string
  401. */
  402. protected static function generateKey(): string
  403. {
  404. $key = '';
  405. for ($i = 0; $i < 16; $i++) {
  406. $key .= chr(rand(33, 126));
  407. }
  408. return base64_encode($key);
  409. }
  410. protected function parseUri($uri): UriInterface
  411. {
  412. if ($uri instanceof UriInterface) {
  413. $uri = $uri;
  414. } elseif (is_string($uri)) {
  415. try {
  416. $uri = new Uri($uri);
  417. } catch (InvalidArgumentException $e) {
  418. throw new BadUriException("Invalid URI '{$uri}' provided.", 0, $e);
  419. }
  420. } else {
  421. throw new BadUriException("Provided URI must be a UriInterface or string.");
  422. }
  423. if (!in_array($uri->getScheme(), ['ws', 'wss'])) {
  424. throw new BadUriException("Invalid URI scheme, must be 'ws' or 'wss'.");
  425. }
  426. return $uri;
  427. }
  428. }