Server.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  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 Closure;
  10. use ErrorException;
  11. use Phrity\Util\ErrorHandler;
  12. use Psr\Log\{
  13. LoggerAwareInterface,
  14. LoggerAwareTrait,
  15. LoggerInterface,
  16. NullLogger
  17. };
  18. use Throwable;
  19. use WebSocket\Message\Factory;
  20. class Server implements LoggerAwareInterface
  21. {
  22. use LoggerAwareTrait; // Provides setLogger(LoggerInterface $logger)
  23. use OpcodeTrait;
  24. // Default options
  25. protected static $default_options = [
  26. 'filter' => ['text', 'binary'],
  27. 'fragment_size' => 4096,
  28. 'logger' => null,
  29. 'port' => 8000,
  30. 'return_obj' => false,
  31. 'timeout' => null,
  32. ];
  33. protected $port;
  34. protected $listening;
  35. protected $request;
  36. protected $request_path;
  37. private $connections = [];
  38. private $options = [];
  39. private $listen = false;
  40. private $last_opcode;
  41. /* ---------- Magic methods ------------------------------------------------------ */
  42. /**
  43. * @param array $options
  44. * Associative array containing:
  45. * - filter: Array of opcodes to handle. Default: ['text', 'binary'].
  46. * - fragment_size: Set framgemnt size. Default: 4096
  47. * - logger: PSR-3 compatible logger. Default NullLogger.
  48. * - port: Chose port for listening. Default 8000.
  49. * - return_obj: If receive() function return Message instance. Default false.
  50. * - timeout: Set the socket timeout in seconds.
  51. */
  52. public function __construct(array $options = [])
  53. {
  54. $this->options = array_merge(self::$default_options, [
  55. 'logger' => new NullLogger(),
  56. ], $options);
  57. $this->port = $this->options['port'];
  58. $this->setLogger($this->options['logger']);
  59. $error = $errno = $errstr = null;
  60. set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$error) {
  61. $this->logger->warning($message, ['severity' => $severity]);
  62. $error = $message;
  63. }, E_ALL);
  64. do {
  65. $this->listening = stream_socket_server("tcp://0.0.0.0:$this->port", $errno, $errstr);
  66. } while ($this->listening === false && $this->port++ < 10000);
  67. restore_error_handler();
  68. if (!$this->listening) {
  69. $error = "Could not open listening socket: {$errstr} ({$errno}) {$error}";
  70. $this->logger->error($error);
  71. throw new ConnectionException($error, (int)$errno);
  72. }
  73. $this->logger->info("Server listening to port {$this->port}");
  74. }
  75. /**
  76. * Get string representation of instance.
  77. * @return string String representation.
  78. */
  79. public function __toString(): string
  80. {
  81. return sprintf(
  82. "%s(%s)",
  83. get_class($this),
  84. $this->getName() ?: 'closed'
  85. );
  86. }
  87. /* ---------- Server operations -------------------------------------------------- */
  88. /**
  89. * Accept a single incoming request.
  90. * Note that this operation will block accepting additional requests.
  91. * @return bool True if listening.
  92. */
  93. public function accept(): bool
  94. {
  95. $this->disconnect();
  96. return (bool)$this->listening;
  97. }
  98. /* ---------- Server option functions -------------------------------------------- */
  99. /**
  100. * Get current port.
  101. * @return int port.
  102. */
  103. public function getPort(): int
  104. {
  105. return $this->port;
  106. }
  107. /**
  108. * Set timeout.
  109. * @param int $timeout Timeout in seconds.
  110. */
  111. public function setTimeout(int $timeout): void
  112. {
  113. $this->options['timeout'] = $timeout;
  114. if (!$this->isConnected()) {
  115. return;
  116. }
  117. foreach ($this->connections as $connection) {
  118. $connection->setTimeout($timeout);
  119. $connection->setOptions($this->options);
  120. }
  121. }
  122. /**
  123. * Set fragmentation size.
  124. * @param int $fragment_size Fragment size in bytes.
  125. * @return self.
  126. */
  127. public function setFragmentSize(int $fragment_size): self
  128. {
  129. $this->options['fragment_size'] = $fragment_size;
  130. foreach ($this->connections as $connection) {
  131. $connection->setOptions($this->options);
  132. }
  133. return $this;
  134. }
  135. /**
  136. * Get fragmentation size.
  137. * @return int $fragment_size Fragment size in bytes.
  138. */
  139. public function getFragmentSize(): int
  140. {
  141. return $this->options['fragment_size'];
  142. }
  143. /* ---------- Connection broadcast operations ------------------------------------ */
  144. /**
  145. * Broadcast text message to all conenctions.
  146. * @param string $payload Content as string.
  147. */
  148. public function text(string $payload): void
  149. {
  150. $this->send($payload);
  151. }
  152. /**
  153. * Broadcast binary message to all conenctions.
  154. * @param string $payload Content as binary string.
  155. */
  156. public function binary(string $payload): void
  157. {
  158. $this->send($payload, 'binary');
  159. }
  160. /**
  161. * Broadcast ping message to all conenctions.
  162. * @param string $payload Optional text as string.
  163. */
  164. public function ping(string $payload = ''): void
  165. {
  166. $this->send($payload, 'ping');
  167. }
  168. /**
  169. * Broadcast pong message to all conenctions.
  170. * @param string $payload Optional text as string.
  171. */
  172. public function pong(string $payload = ''): void
  173. {
  174. $this->send($payload, 'pong');
  175. }
  176. /**
  177. * Send message on all connections.
  178. * @param string $payload Message to send.
  179. * @param string $opcode Opcode to use, default: 'text'.
  180. * @param bool $masked If message should be masked default: true.
  181. */
  182. public function send(string $payload, string $opcode = 'text', bool $masked = true): void
  183. {
  184. if (!$this->isConnected()) {
  185. $this->connect();
  186. }
  187. if (!in_array($opcode, array_keys(self::$opcodes))) {
  188. $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'.";
  189. $this->logger->warning($warning);
  190. throw new BadOpcodeException($warning);
  191. }
  192. $factory = new Factory();
  193. $message = $factory->create($opcode, $payload);
  194. foreach ($this->connections as $connection) {
  195. $connection->pushMessage($message, $masked);
  196. }
  197. }
  198. /**
  199. * Close all connections.
  200. * @param int $status Close status, default: 1000.
  201. * @param string $message Close message, default: 'ttfn'.
  202. */
  203. public function close(int $status = 1000, string $message = 'ttfn'): void
  204. {
  205. foreach ($this->connections as $connection) {
  206. if ($connection->isConnected()) {
  207. $connection->close($status, $message);
  208. }
  209. }
  210. }
  211. /**
  212. * Disconnect all connections.
  213. */
  214. public function disconnect(): void
  215. {
  216. foreach ($this->connections as $connection) {
  217. if ($connection->isConnected()) {
  218. $connection->disconnect();
  219. }
  220. }
  221. $this->connections = [];
  222. }
  223. /**
  224. * Receive message from single connection.
  225. * Note that this operation will block reading and only read from first available connection.
  226. * @return mixed Message, text or null depending on settings.
  227. */
  228. public function receive()
  229. {
  230. $filter = $this->options['filter'];
  231. $return_obj = $this->options['return_obj'];
  232. if (!$this->isConnected()) {
  233. $this->connect();
  234. }
  235. $connection = current($this->connections);
  236. while (true) {
  237. $message = $connection->pullMessage();
  238. $opcode = $message->getOpcode();
  239. if (in_array($opcode, $filter)) {
  240. $this->last_opcode = $opcode;
  241. $return = $return_obj ? $message : $message->getContent();
  242. break;
  243. } elseif ($opcode == 'close') {
  244. $this->last_opcode = null;
  245. $return = $return_obj ? $message : null;
  246. break;
  247. }
  248. }
  249. return $return;
  250. }
  251. /* ---------- Connection functions ----------------------------------------------- */
  252. /**
  253. * Get requested path from last connection.
  254. * @return string Path.
  255. */
  256. public function getPath(): string
  257. {
  258. return $this->request_path;
  259. }
  260. /**
  261. * Get request from last connection.
  262. * @return array Request.
  263. */
  264. public function getRequest(): array
  265. {
  266. return $this->request;
  267. }
  268. /**
  269. * Get headers from last connection.
  270. * @return string|null Headers.
  271. */
  272. public function getHeader($header): ?string
  273. {
  274. foreach ($this->request as $row) {
  275. if (stripos($row, $header) !== false) {
  276. list($headername, $headervalue) = explode(":", $row);
  277. return trim($headervalue);
  278. }
  279. }
  280. return null;
  281. }
  282. /**
  283. * Get last received opcode.
  284. * @return string|null Opcode.
  285. */
  286. public function getLastOpcode(): ?string
  287. {
  288. return $this->last_opcode;
  289. }
  290. /**
  291. * Get close status from single connection.
  292. * @return int|null Close status.
  293. */
  294. public function getCloseStatus(): ?int
  295. {
  296. return $this->connections ? current($this->connections)->getCloseStatus() : null;
  297. }
  298. /**
  299. * If Server has active connections.
  300. * @return bool True if active connection.
  301. */
  302. public function isConnected(): bool
  303. {
  304. foreach ($this->connections as $connection) {
  305. if ($connection->isConnected()) {
  306. return true;
  307. }
  308. }
  309. return false;
  310. }
  311. /**
  312. * Get name of local socket from single connection.
  313. * @return string|null Name of local socket.
  314. */
  315. public function getName(): ?string
  316. {
  317. return $this->isConnected() ? current($this->connections)->getName() : null;
  318. }
  319. /**
  320. * Get name of remote socket from single connection.
  321. * @return string|null Name of remote socket.
  322. */
  323. public function getRemoteName(): ?string
  324. {
  325. return $this->isConnected() ? current($this->connections)->getRemoteName() : null;
  326. }
  327. /**
  328. * @deprecated Will be removed in future version.
  329. */
  330. public function getPier(): ?string
  331. {
  332. trigger_error(
  333. 'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.',
  334. E_USER_DEPRECATED
  335. );
  336. return $this->getRemoteName();
  337. }
  338. /* ---------- Helper functions --------------------------------------------------- */
  339. // Connect when read/write operation is performed.
  340. private function connect(): void
  341. {
  342. try {
  343. $handler = new ErrorHandler();
  344. $socket = $handler->with(function () {
  345. if (isset($this->options['timeout'])) {
  346. $socket = stream_socket_accept($this->listening, $this->options['timeout']);
  347. } else {
  348. $socket = stream_socket_accept($this->listening);
  349. }
  350. if (!$socket) {
  351. throw new ErrorException('No socket');
  352. }
  353. return $socket;
  354. });
  355. } catch (ErrorException $e) {
  356. $error = "Server failed to connect. {$e->getMessage()}";
  357. $this->logger->error($error, ['severity' => $e->getSeverity()]);
  358. throw new ConnectionException($error, 0, [], $e);
  359. }
  360. $connection = new Connection($socket, $this->options);
  361. $connection->setLogger($this->logger);
  362. if (isset($this->options['timeout'])) {
  363. $connection->setTimeout($this->options['timeout']);
  364. }
  365. $this->logger->info("Client has connected to port {port}", [
  366. 'port' => $this->port,
  367. 'peer' => $connection->getRemoteName(),
  368. ]);
  369. $this->performHandshake($connection);
  370. $this->connections = ['*' => $connection];
  371. }
  372. // Perform upgrade handshake on new connections.
  373. private function performHandshake(Connection $connection): void
  374. {
  375. $request = '';
  376. do {
  377. $buffer = $connection->getLine(1024, "\r\n");
  378. $request .= $buffer . "\n";
  379. $metadata = $connection->getMeta();
  380. } while (!$connection->eof() && $metadata['unread_bytes'] > 0);
  381. if (!preg_match('/GET (.*) HTTP\//mUi', $request, $matches)) {
  382. $error = "No GET in request: {$request}";
  383. $this->logger->error($error);
  384. throw new ConnectionException($error);
  385. }
  386. $get_uri = trim($matches[1]);
  387. $uri_parts = parse_url($get_uri);
  388. $this->request = explode("\n", $request);
  389. $this->request_path = $uri_parts['path'];
  390. /// @todo Get query and fragment as well.
  391. if (!preg_match('#Sec-WebSocket-Key:\s(.*)$#mUi', $request, $matches)) {
  392. $error = "Client had no Key in upgrade request: {$request}";
  393. $this->logger->error($error);
  394. throw new ConnectionException($error);
  395. }
  396. $key = trim($matches[1]);
  397. /// @todo Validate key length and base 64...
  398. $response_key = base64_encode(pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));
  399. $header = "HTTP/1.1 101 Switching Protocols\r\n"
  400. . "Upgrade: websocket\r\n"
  401. . "Connection: Upgrade\r\n"
  402. . "Sec-WebSocket-Accept: $response_key\r\n"
  403. . "\r\n";
  404. $connection->write($header);
  405. $this->logger->debug("Handshake on {$get_uri}");
  406. }
  407. }