WebSocketServer::handshake()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 17
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 6
Bugs 1 Features 0
Metric Value
cc 2
eloc 10
c 6
b 1
f 0
nc 2
nop 2
dl 0
loc 17
rs 9.9332
1
<?php
2
3
namespace WSSC;
4
5
use WSSC\Components\Connection;
6
use WSSC\Components\OriginComponent;
7
use WSSC\Components\ServerConfig;
8
use WSSC\Components\WssMain;
9
use WSSC\Contracts\CommonsContract;
10
use WSSC\Contracts\WebSocket;
11
use WSSC\Contracts\WebSocketServerContract;
12
use WSSC\Exceptions\ConnectionException;
13
use WSSC\Exceptions\WebSocketException;
14
15
/**
16
 * Class WebSocketServer
17
 * @package WSSC
18
 */
19
class WebSocketServer extends WssMain implements WebSocketServerContract
20
{
21
    private const MAX_BYTES_READ = 8192;
22
    private const HEADER_BYTES_READ = 1024;
23
24
    /**
25
     * @var ServerConfig
26
     */
27
    protected ServerConfig $config;
28
29
    /**
30
     * @var array
31
     */
32
    private array $clients = [];
33
34
    /**
35
     * @var array
36
     */
37
    private array $headersUpgrade = [];
38
39
    /**
40
     * @var int
41
     */
42
    private int $maxClients = 1;
43
44
    /**
45
     * @var WebSocket
46
     */
47
    private WebSocket $handler;
48
49
    /**
50
     * @var bool
51
     */
52
    private bool $stepRecursion = true;
53
54
    /*
55
     * @var bool
56
     */
57
    private bool $printException = true;
58
59
    /**
60
     * WebSocketServer constructor.
61
     *
62
     * @param WebSocket $handler
63
     * @param ServerConfig $config
64
     */
65
    public function __construct(
66
        WebSocket $handler,
67
        ServerConfig $config
68
    )
69
    {
70
        ini_set('default_socket_timeout', 5); // this should be >= 5 sec, otherwise there will be broken pipe - tested
71
72
        $this->handler = $handler;
73
        $this->config = $config;
74
        $this->setIsPcntlLoaded(extension_loaded('pcntl'));
75
    }
76
77
    /**
78
     * Configure if error exceptions should be printed
79
     *
80
     * @return self
81
     */
82
    public function printException(bool $printException): self
83
    {
84
        $this->printException = $printException;
85
86
        return $this;
87
    }
88
89
    /**
90
     * Runs main process - Anscestor with server socket on TCP
91
     *
92
     * @throws WebSocketException
93
     * @throws ConnectionException
94
     */
95
    public function run(): void
96
    {
97
        $context = stream_context_create();
98
        $errno = null;
99
        $errorMessage = '';
100
101
        if ($this->config->isSsl() === true) {
102
            stream_context_set_option($context, 'ssl', 'allow_self_signed', $this->config->getAllowSelfSigned());
103
            stream_context_set_option($context, 'ssl', 'verify_peer', false);
104
105
            if (is_file($this->config->getLocalCert()) === false || is_file($this->config->getLocalPk()) === false) {
106
                throw new WebSocketException('SSL certificates must be valid pem files', CommonsContract::SERVER_INVALID_STREAM_CONTEXT);
107
            }
108
            $isLocalCertSet = stream_context_set_option($context, 'ssl', 'local_cert', $this->config->getLocalCert());
109
            $isLocalPkSet = stream_context_set_option($context, 'ssl', 'local_pk', $this->config->getLocalPk());
110
111
            if ($isLocalCertSet === false || $isLocalPkSet === false) {
112
                throw new WebSocketException('SSL certificates could not be set correctly', CommonsContract::SERVER_INVALID_STREAM_CONTEXT);
113
            }
114
        }
115
116
        $server = stream_socket_server("tcp://{$this->config->getHost()}:{$this->config->getPort()}", $errno,
117
            $errorMessage, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context);
118
119
        if ($server === false) {
120
            throw new WebSocketException('Could not bind to socket: ' . $errno . ' - ' . $errorMessage . PHP_EOL,
121
                CommonsContract::SERVER_COULD_NOT_BIND_TO_SOCKET);
122
        }
123
124
        @cli_set_process_title($this->config->getProcessName());
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for cli_set_process_title(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

124
        /** @scrutinizer ignore-unhandled */ @cli_set_process_title($this->config->getProcessName());

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
125
        $this->eventLoop($server);
126
    }
127
128
    /**
129
     * Recursive event loop that input intu recusion by remainder = 0 - thus when N users,
130
     * and when forks equals true which prevents it from infinite recursive iterations
131
     *
132
     * @param resource $server server connection
133
     * @param bool $fork flag to fork or run event loop
134
     * @throws WebSocketException
135
     * @throws ConnectionException
136
     */
137
    private function eventLoop($server, bool $fork = false): void
138
    {
139
        if ($fork === true && $this->isPcntlLoaded()) {
140
            $pid = pcntl_fork();
141
142
            if ($pid) { // run eventLoop in parent
143
                @cli_set_process_title($this->config->getProcessName());
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for cli_set_process_title(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

143
                /** @scrutinizer ignore-unhandled */ @cli_set_process_title($this->config->getProcessName());

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
144
                $this->eventLoop($server);
145
            }
146
        } else {
147
            $this->looping($server);
148
        }
149
    }
150
151
    /**
152
     * @param resource $server
153
     * @throws WebSocketException
154
     * @throws ConnectionException
155
     */
156
    private function looping($server): void
157
    {
158
        $loopingDelay = $this->config->getLoopingDelay();
159
160
        while (true) {
161
            // usleep require microseconds
162
            if ($loopingDelay) {
163
                usleep($loopingDelay * 1000);
164
            }
165
166
            $totalClients = count($this->clients) + 1;
167
168
            // maxClients prevents process fork on count down
169
            if ($totalClients > $this->maxClients) {
170
                $this->maxClients = $totalClients;
171
            }
172
173
            $doFork = $this->config->isForking() === true
174
                && $totalClients !== 0 // avoid 0 process creation
175
                && $this->stepRecursion === true // only once
176
                && $this->maxClients === $totalClients // only if stack grows
177
                && $totalClients % $this->config->getClientsPerFork() === 0; // only when N is there
178
            if ($doFork) {
179
                $this->stepRecursion = false;
180
                $this->eventLoop($server, true);
181
            }
182
            $this->lessConnThanProc($totalClients, $this->maxClients);
183
184
            //prepare readable sockets
185
            $readSocks = $this->clients;
186
            $readSocks[] = $server;
187
            $this->cleanSocketResources($readSocks);
188
189
            //start reading and use a large timeout
190
            if (!stream_select($readSocks, $write, $except, $this->config->getStreamSelectTimeout())) {
191
                throw new WebSocketException('something went wrong while selecting',
192
                    CommonsContract::SERVER_SELECT_ERROR);
193
            }
194
195
            //new client
196
            if (in_array($server, $readSocks, false)) {
197
                $this->acceptNewClient($server, $readSocks);
198
                if ($this->config->isCheckOrigin() && $this->config->isOriginHeader() === false) {
199
                    continue;
200
                }
201
            }
202
203
            //message from existing client
204
            $this->messagesWorker($readSocks);
205
        }
206
    }
207
208
    /**
209
     * @param resource $server
210
     * @param array $readSocks
211
     * @throws ConnectionException
212
     */
213
    private function acceptNewClient($server, array &$readSocks): void
214
    {
215
        $newClient = stream_socket_accept($server, -1); // must be 0 to non-block
216
        if ($newClient) {
0 ignored issues
show
introduced by
$newClient is of type resource, thus it always evaluated to false.
Loading history...
217
            if ($this->config->isSsl() === true) {
218
                $isEnabled = stream_socket_enable_crypto($newClient, true, $this->config->getCryptoType());
219
                if ($isEnabled === false) { // couldn't enable crypto - let's try one more time
220
                    return;
221
                }
222
            }
223
224
            // important to read from headers here coz later client will change and there will be only msgs on pipe
225
            $headers = fread($newClient, self::HEADER_BYTES_READ);
226
            if ($this->config->isCheckOrigin()) {
227
                $hasOrigin = (new OriginComponent($this->config, $newClient))->checkOrigin($headers);
228
                $this->config->setOriginHeader($hasOrigin);
229
                if ($hasOrigin === false) {
230
                    return;
231
                }
232
            }
233
234
            if (empty($this->handler->pathParams[0]) === false) {
235
                $this->setPathParams($headers);
236
            }
237
238
            $this->clients[] = $newClient;
239
            $this->stepRecursion = true; // set on new client - remainder % is always 0
240
241
            // trigger OPEN event
242
            $this->handler->onOpen(new Connection($newClient, $this->clients));
243
            $this->handshake($newClient, $headers);
244
        }
245
246
        //delete the server socket from the read sockets
247
        unset($readSocks[array_search($server, $readSocks, false)]);
248
    }
249
250
    /**
251
     * @param array $readSocks
252
     * @uses onPing
253
     * @uses onPong
254
     * @uses onMessage
255
     */
256
    private function messagesWorker(array $readSocks): void
257
    {
258
        foreach ($readSocks as $kSock => $sock) {
259
            $data = $this->decode(fread($sock, self::MAX_BYTES_READ));
260
            if ($data !== null) {
261
                $dataType = null;
262
                $dataPayload = null;
263
                if ($data !== false) { // payload is too large - waiting for remained data
264
                    $dataType = $data['type'];
265
                    $dataPayload = $data['payload'];
266
                }
267
268
                // to manipulate connection through send/close methods via handler, specified in IConnection
269
                $cureentConn = new Connection($sock, $this->clients);
270
                if (empty($data) || $dataType === self::EVENT_TYPE_CLOSE) { // close event triggered from client - browser tab or close socket event
271
                    // trigger CLOSE event
272
                    try {
273
                        $this->handler->onClose($cureentConn);
274
                    } catch (WebSocketException $e) {
275
                        $this->handleMessagesWorkerException($cureentConn, $e);
276
                    }
277
278
                    // to avoid event leaks
279
                    unset($this->clients[array_search($sock, $this->clients)], $readSocks[$kSock]);
280
                    continue;
281
                }
282
283
                $isSupportedMethod = empty(self::MAP_EVENT_TYPE_TO_METHODS[$dataType]) === false
284
                    && method_exists($this->handler, self::MAP_EVENT_TYPE_TO_METHODS[$dataType]);
285
                if ($isSupportedMethod) {
286
                    try {
287
                        // dynamic call: onMessage, onPing, onPong
288
                        $this->handler->{self::MAP_EVENT_TYPE_TO_METHODS[$dataType]}($cureentConn, $dataPayload);
289
                    } catch (WebSocketException $e) {
290
                        $this->handleMessagesWorkerException($cureentConn, $e);
291
                    }
292
                }
293
            }
294
        }
295
    }
296
297
    /**
298
     * Handshakes/upgrade and key parse
299
     *
300
     * @param resource $client Source client socket to write
301
     * @param string $headers Headers that client has been sent
302
     * @return string   socket handshake key (Sec-WebSocket-Key)| false on parse error
303
     * @throws ConnectionException
304
     */
305
    private function handshake($client, string $headers): string
306
    {
307
        $match = [];
308
        preg_match(self::SEC_WEBSOCKET_KEY_PTRN, $headers, $match);
309
        if (empty($match[1])) {
310
            return '';
311
        }
312
313
        $key = $match[1];
314
        // sending header according to WebSocket Protocol
315
        $secWebSocketAccept = base64_encode(sha1(trim($key) . self::HEADER_WEBSOCKET_ACCEPT_HASH, true));
316
        $this->setHeadersUpgrade($secWebSocketAccept);
317
        $upgradeHeaders = $this->getHeadersUpgrade();
318
319
        fwrite($client, $upgradeHeaders);
320
321
        return $key;
322
    }
323
324
    /**
325
     * Sets an array of headers needed to upgrade server/client connection
326
     *
327
     * @param string $secWebSocketAccept base64 encoded Sec-WebSocket-Accept header
328
     */
329
    private function setHeadersUpgrade(string $secWebSocketAccept): void
330
    {
331
        $this->headersUpgrade = [
332
            self::HEADERS_UPGRADE_KEY => self::HEADERS_UPGRADE_VALUE,
333
            self::HEADERS_CONNECTION_KEY => self::HEADERS_CONNECTION_VALUE,
334
            self::HEADERS_SEC_WEBSOCKET_ACCEPT_KEY => ' ' . $secWebSocketAccept
335
            // the space before key is really important
336
        ];
337
    }
338
339
    /**
340
     * Retreives headers from an array of headers to upgrade server/client connection
341
     *
342
     * @return string   Headers to Upgrade communication connection
343
     * @throws ConnectionException
344
     */
345
    private function getHeadersUpgrade(): string
346
    {
347
        $handShakeHeaders = self::HEADER_HTTP1_1 . self::HEADERS_EOL;
348
        if (empty($this->headersUpgrade)) {
349
            throw new ConnectionException('Headers for upgrade handshake are not set' . PHP_EOL,
350
                CommonsContract::SERVER_HEADERS_NOT_SET);
351
        }
352
353
        foreach ($this->headersUpgrade as $key => $header) {
354
            $handShakeHeaders .= $key . ':' . $header . self::HEADERS_EOL;
355
            if ($key === self::HEADERS_SEC_WEBSOCKET_ACCEPT_KEY) { // add additional EOL fo Sec-WebSocket-Accept
356
                $handShakeHeaders .= self::HEADERS_EOL;
357
            }
358
        }
359
360
        return $handShakeHeaders;
361
    }
362
363
    /**
364
     * Parses parameters from GET on web-socket client connection before handshake
365
     *
366
     * @param string $headers
367
     */
368
    private function setPathParams(string $headers): void
369
    {
370
        if (empty($this->handler->pathParams) === false) {
371
            $matches = [];
372
            preg_match('/GET\s(.*?)\s/', $headers, $matches);
373
            $left = $matches[1];
374
375
            foreach ($this->handler->pathParams as $k => $param) {
376
                if (empty($this->handler->pathParams[$k + 1]) && strpos($left, '/', 1) === false) {
377
                    // do not eat last char if there is no / at the end
378
                    $this->handler->pathParams[$param] = substr($left, strpos($left, '/') + 1);
379
                } else {
380
                    // eat both slashes
381
                    $this->handler->pathParams[$param] = substr($left, strpos($left, '/') + 1,
382
                        strpos($left, '/', 1) - 1);
383
                }
384
385
                // clear the declaration of parsed param
386
                unset($this->handler->pathParams[array_search($param, $this->handler->pathParams, false)]);
387
                $left = substr($left, strpos($left, '/', 1));
388
            }
389
        }
390
    }
391
392
    /**
393
     * Manage messagesWorker Exceptions
394
     *
395
     * @param Connection $connection
396
     * @param WebSocketException $e
397
     */
398
    private function handleMessagesWorkerException(Connection $connection, WebSocketException $e): void
399
    {
400
        $this->handler->onError($connection, $e);
401
402
        if ($this->printException) {
403
            $e->printStack();
404
        }
405
    }
406
}
407