Completed
Push — master ( e50618...82773c )
by Arthur
01:49
created

WscMain::validateResponse()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 17
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 11
nc 3
nop 4
1
<?php
2
3
namespace WSSC\Components;
4
5
use WSSC\Contracts\CommonsContract;
6
use WSSC\Contracts\WscCommonsContract;
7
use WSSC\Exceptions\BadOpcodeException;
8
use WSSC\Exceptions\BadUriException;
9
use WSSC\Exceptions\ConnectionException;
10
11
class WscMain implements WscCommonsContract
12
{
13
14
    private $socket;
15
    private $isConnected = false;
16
    private $isClosing = false;
17
    private $lastOpcode;
18
    private $closeStatus;
19
    private $hugePayload;
20
21
    private static $opcodes = [
22
        CommonsContract::EVENT_TYPE_CONTINUATION => 0,
23
        CommonsContract::EVENT_TYPE_TEXT         => 1,
24
        CommonsContract::EVENT_TYPE_BINARY       => 2,
25
        CommonsContract::EVENT_TYPE_CLOSE        => 8,
26
        CommonsContract::EVENT_TYPE_PING         => 9,
27
        CommonsContract::EVENT_TYPE_PONG         => 10,
28
    ];
29
30
    protected $socketUrl = '';
31
    protected $options = [];
32
33
    /**
34
     * @throws \InvalidArgumentException
35
     * @throws BadUriException
36
     * @throws ConnectionException
37
     * @throws \Exception
38
     */
39
    protected function connect() : void
40
    {
41
        $urlParts = parse_url($this->socketUrl);
42
        $scheme = $urlParts['scheme'];
43
        $host = $urlParts['host'];
44
        $user = isset($urlParts['user']) ? $urlParts['user'] : '';
45
        $pass = isset($urlParts['pass']) ? $urlParts['pass'] : '';
46
        $port = isset($urlParts['port']) ? $urlParts['port'] : ($scheme === 'wss' ? 443 : 80);
47
48
        $pathWithQuery = $this->getPathWithQuery($urlParts);
49
        $hostUri = $this->getHostUri($scheme, $host);
50
        // Set the stream context options if they're already set in the config
51
        $context = $this->getStreamContext();
52
        $this->socket = @stream_socket_client(
53
            $hostUri . ':' . $port, $errno, $errstr, $this->options['timeout'], STREAM_CLIENT_CONNECT, $context
54
        );
55
        if ($this->socket === false) {
56
            throw new ConnectionException(
57
                "Could not open socket to \"$host:$port\": $errstr ($errno)."
58
            );
59
        }
60
61
        // Set timeout on the stream as well.
62
        stream_set_timeout($this->socket, $this->options['timeout']);
63
64
        // Generate the WebSocket key.
65
        $key = $this->generateKey();
66
        $headers = [
67
            'Host'                  => $host . ':' . $port,
68
            'User-Agent'            => 'websocket-client-php',
69
            'Connection'            => 'Upgrade',
70
            'Upgrade'               => 'WebSocket',
71
            'Sec-WebSocket-Key'     => $key,
72
            'Sec-Websocket-Version' => '13',
73
        ];
74
75
        // Handle basic authentication.
76
        if ($user || $pass) {
77
            $headers['authorization'] = 'Basic ' . base64_encode($user . ':' . $pass) . "\r\n";
78
        }
79
        // Add and override with headers from options.
80
        if (isset($this->options['headers'])) {
81
            $headers = array_merge($headers, $this->options['headers']);
82
        }
83
84
        $header = $this->getHeaders($pathWithQuery, $headers);
85
        // Send headers.
86
        $this->write($header);
87
        // Get server response header
88
        // @todo Handle version switching
89
        $this->validateResponse($scheme, $host, $pathWithQuery, $key);
90
        $this->isConnected = true;
91
    }
92
93
    /**
94
     * @param string $scheme
95
     * @param string $host
96
     * @return string
97
     * @throws BadUriException
98
     */
99
    private function getHostUri(string $scheme, string $host) : string
100
    {
101
        if (in_array($scheme, ['ws', 'wss'], true) === false) {
102
            throw new BadUriException(
103
                "Url should have scheme ws or wss, not '$scheme' from URI '$this->socketUrl' ."
104
            );
105
        }
106
107
        return ($scheme === 'wss' ? 'ssl' : 'tcp') . '://' . $host;
108
    }
109
110
    /**
111
     * @param string $scheme
112
     * @param string $host
113
     * @param string $pathWithQuery
114
     * @param string $key
115
     * @throws ConnectionException
116
     */
117
    private function validateResponse(string $scheme, string $host, string $pathWithQuery, string $key)
118
    {
119
        $response = stream_get_line($this->socket, self::DEFAULT_RESPONSE_HEADER, "\r\n\r\n");
120
        if (!preg_match(self::SEC_WEBSOCKET_ACCEPT_PTTRN, $response, $matches)) {
121
            $address = $scheme . '://' . $host . $pathWithQuery;
122
            throw new ConnectionException(
123
                "Connection to '{$address}' failed: Server sent invalid upgrade response:\n"
124
                . $response
125
            );
126
        }
127
128
        $keyAccept = trim($matches[1]);
129
        $expectedResonse = base64_encode(pack('H*', sha1($key . self::SERVER_KEY_ACCEPT)));
130
        if ($keyAccept !== $expectedResonse) {
131
            throw new ConnectionException('Server sent bad upgrade response.');
132
        }
133
    }
134
135
    /**
136
     * @return mixed|resource
137
     * @throws \InvalidArgumentException
138
     */
139
    private function getStreamContext()
140
    {
141
        if (isset($this->options['context'])) {
142
            // Suppress the error since we'll catch it below
143
            if (@get_resource_type($this->options['context']) === 'stream-context') {
144
                return $this->options['context'];
145
            }
146
147
            throw new \InvalidArgumentException(
148
                "Stream context in \$options['context'] isn't a valid context"
149
            );
150
        }
151
152
        return stream_context_create();
153
    }
154
155
    /**
156
     * @param mixed $urlParts
157
     * @return string
158
     */
159
    private function getPathWithQuery($urlParts) : string
160
    {
161
        $path = isset($urlParts['path']) ? $urlParts['path'] : '/';
162
        $query = isset($urlParts['query']) ? $urlParts['query'] : '';
163
        $fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : '';
164
        $pathWithQuery = $path;
165
        if (!empty($query)) {
166
            $pathWithQuery .= '?' . $query;
167
        }
168
        if (!empty($fragment)) {
169
            $pathWithQuery .= '#' . $fragment;
170
        }
171
        return $pathWithQuery;
172
    }
173
174
    /**
175
     * @param string $pathWithQuery
176
     * @param array $headers
177
     * @return string
178
     */
179
    private function getHeaders(string $pathWithQuery, array $headers) : string
180
    {
181
        return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n"
182
            . implode(
183
                "\r\n", array_map(
184
                    function ($key, $value) {
185
                        return "$key: $value";
186
                    }, array_keys($headers), $headers
187
                )
188
            )
189
            . "\r\n\r\n";
190
    }
191
192
    /**
193
     * @return string
194
     */
195
    public function getLastOpcode() : string
196
    {
197
        return $this->lastOpcode;
198
    }
199
200
    /**
201
     * @return int
202
     */
203
    public function getCloseStatus() : int
204
    {
205
        return $this->closeStatus;
206
    }
207
208
    /**
209
     * @return bool
210
     */
211
    public function isConnected() : bool
212
    {
213
        return $this->isConnected;
214
    }
215
216
    /**
217
     * @param int $timeout
218
     * @param null $microSecs
219
     */
220
    public function setTimeout(int $timeout, $microSecs = null)
221
    {
222
        $this->options['timeout'] = $timeout;
223
224
        if ($this->socket && get_resource_type($this->socket) === 'stream') {
225
            stream_set_timeout($this->socket, $timeout, $microSecs);
226
        }
227
    }
228
229
    public function setFragmentSize($fragmentSize)
230
    {
231
        $this->options['fragment_size'] = $fragmentSize;
232
        return $this;
233
    }
234
235
    public function getFragmentSize()
236
    {
237
        return $this->options['fragment_size'];
238
    }
239
240
    public function send($payload, $opcode = 'text', $masked = true)
241
    {
242
        if (!$this->isConnected) {
243
            $this->connect();
244
        }
245
        if (array_key_exists($opcode, self::$opcodes) === false) {
246
            throw new BadOpcodeException("Bad opcode '$opcode'.  Try 'text' or 'binary'.");
247
        }
248
        echo $payload;
249
        // record the length of the payload
250
        $payload_length = strlen($payload);
251
252
        $fragment_cursor = 0;
253
        // while we have data to send
254
        while ($payload_length > $fragment_cursor) {
255
            // get a fragment of the payload
256
            $sub_payload = substr($payload, $fragment_cursor, $this->options['fragment_size']);
257
258
            // advance the cursor
259
            $fragment_cursor += $this->options['fragment_size'];
260
261
            // is this the final fragment to send?
262
            $final = $payload_length <= $fragment_cursor;
263
264
            // send the fragment
265
            $this->sendFragment($final, $sub_payload, $opcode, $masked);
266
267
            // all fragments after the first will be marked a continuation
268
            $opcode = 'continuation';
269
        }
270
    }
271
272
    /**
273
     * @param $final
274
     * @param $payload
275
     * @param $opcode
276
     * @param $masked
277
     * @throws ConnectionException
278
     * @throws \Exception
279
     */
280
    protected function sendFragment($final, $payload, $opcode, $masked)
281
    {
282
        // Binary string for header.
283
        $frameHeadBin = '';
284
        // Write FIN, final fragment bit.
285
        $frameHeadBin .= (bool)$final ? '1' : '0';
286
        // RSV 1, 2, & 3 false and unused.
0 ignored issues
show
Unused Code Comprehensibility introduced by
43% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
287
        $frameHeadBin .= '000';
288
        // Opcode rest of the byte.
289
        $frameHeadBin .= sprintf('%04b', self::$opcodes[$opcode]);
290
        // Use masking?
291
        $frameHeadBin .= $masked ? '1' : '0';
292
293
        // 7 bits of payload length...
294
        $payloadLen = strlen($payload);
295
        if ($payloadLen > self::MAX_BYTES_READ) {
296
            $frameHeadBin .= decbin(self::MASK_127);
297
            $frameHeadBin .= sprintf('%064b', $payloadLen);
298
        } else if ($payloadLen > self::MASK_125) {
299
            $frameHeadBin .= decbin(self::MASK_126);
300
            $frameHeadBin .= sprintf('%016b', $payloadLen);
301
        } else {
302
            $frameHeadBin .= sprintf('%07b', $payloadLen);
303
        }
304
305
        $frame = '';
306
307
        // Write frame head to frame.
308
        foreach (str_split($frameHeadBin, 8) as $binstr) {
309
            $frame .= chr(bindec($binstr));
310
        }
311
        // Handle masking
312
        if ($masked) {
313
            // generate a random mask:
314
            $mask = '';
315 View Code Duplication
            for ($i = 0; $i < 4; $i++) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
316
                $mask .= chr(random_int(0, 255));
317
            }
318
            $frame .= $mask;
319
        }
320
321
        // Append payload to frame:
322 View Code Duplication
        for ($i = 0; $i < $payloadLen; $i++) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
323
            $frame .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i];
0 ignored issues
show
Bug introduced by
The variable $mask does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
324
        }
325
326
        $this->write($frame);
327
    }
328
329
    public function receive()
330
    {
331
        if (!$this->isConnected) {
332
            $this->connect();
333
        }
334
        $this->hugePayload = '';
335
336
        $response = NULL;
337
        while (NULL === $response) {
338
            $response = $this->receiveFragment();
339
        }
340
        return $response;
341
    }
342
343
    /**
344
     * @return string
345
     * @throws BadOpcodeException
346
     * @throws ConnectionException
347
     */
348
    protected function receiveFragment() : string
349
    {
350
        // Just read the main fragment information first.
351
        $data = $this->read(2);
352
353
        // Is this the final fragment?  // Bit 0 in byte 0
354
        /// @todo Handle huge payloads with multiple fragments.
355
        $final = (bool)(ord($data[0]) & 1 << 7);
356
357
        // Parse opcode
358
        $opcode_int = ord($data[0]) & 31; // Bits 4-7
359
        $opcode_ints = array_flip(self::$opcodes);
360
        if (!array_key_exists($opcode_int, $opcode_ints)) {
361
            throw new ConnectionException("Bad opcode in websocket frame: $opcode_int");
362
        }
363
        $opcode = $opcode_ints[$opcode_int];
364
365
        // record the opcode if we are not receiving a continutation fragment
366
        if ($opcode !== 'continuation') {
367
            $this->lastOpcode = $opcode;
368
        }
369
370
        $payloadLength = $this->getPayloadLength($data);
371
        $payload = $this->getPayloadData($data, $payloadLength);
372
        if ($opcode === CommonsContract::EVENT_TYPE_CLOSE) {
373
            // Get the close status.
374
            if ($payloadLength >= 2) {
375
                $statusBin = $payload[0] . $payload[1];
376
                $status = bindec(sprintf('%08b%08b', ord($payload[0]), ord($payload[1])));
377
                $this->closeStatus = $status;
378
                $payload = substr($payload, 2);
379
380
                if (!$this->isClosing) {
381
                    $this->send($statusBin . 'Close acknowledged: ' . $status, 'close'); // Respond.
382
                }
383
            }
384
385
            if ($this->isClosing) {
386
                $this->isClosing = false; // A close response, all done.
387
            }
388
389
            fclose($this->socket);
390
            $this->isConnected = false;
391
        }
392
393
        if (!$final) {
394
            $this->hugePayload .= $payload;
395
            return NULL;
396
        } // this is the last fragment, and we are processing a huge_payload
397
398
        if ($this->hugePayload) {
399
            $payload = $this->hugePayload .= $payload;
400
            $this->hugePayload = NULL;
401
        }
402
403
        return $payload;
404
    }
405
406
    /**
407
     * @param string $data
408
     * @param int $payloadLength
409
     * @return string
410
     * @throws ConnectionException
411
     */
412
    private function getPayloadData(string $data, int $payloadLength)
413
    {
414
        // Masking?
415
        $mask = (bool)(ord($data[1]) >> 7);  // Bit 0 in byte 1
416
        $payload = '';
417
        $maskingKey = '';
418
        // Get masking key.
419
        if ($mask) {
420
            $maskingKey = $this->read(4);
421
        }
422
        // Get the actual payload, if any (might not be for e.g. close frames.
423
        if ($payloadLength > 0) {
424
            $data = $this->read($payloadLength);
425
426
            if ($mask) {
427
                // Unmask payload.
428
                for ($i = 0; $i < $payloadLength; $i++) {
429
                    $payload .= ($data[$i] ^ $maskingKey[$i % 4]);
430
                }
431
            } else {
432
                $payload = $data;
433
            }
434
        }
435
        return $payload;
436
    }
437
438
    /**
439
     * @param string $data
440
     * @return float|int
441
     * @throws ConnectionException
442
     */
443
    private function getPayloadLength(string $data)
444
    {
445
        $payloadLength = (int)ord($data[1]) & self::MASK_127; // Bits 1-7 in byte 1
446
        if ($payloadLength > self::MASK_125) {
447
            if ($payloadLength === self::MASK_126) {
448
                $data = $this->read(2); // 126: Payload is a 16-bit unsigned int
449
            } else {
450
                $data = $this->read(8); // 127: Payload is a 64-bit unsigned int
451
            }
452
            $payloadLength = bindec(self::sprintB($data));
453
        }
454
        return $payloadLength;
455
    }
456
457
    /**
458
     * Tell the socket to close.
459
     *
460
     * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
461
     * @param string $message A closing message, max 125 bytes.
462
     * @return bool|null|string
463
     * @throws BadOpcodeException
464
     */
465
    public function close(int $status = 1000, string $message = 'ttfn')
466
    {
467
        $statusBin = sprintf('%016b', $status);
468
        $status_str = '';
469
        foreach (str_split($statusBin, 8) as $binstr) {
470
            $status_str .= chr(bindec($binstr));
471
        }
472
        $this->send($status_str . $message, 'close', true);
473
        $this->isClosing = true;
474
        return $this->receive(); // Receiving a close frame will close the socket now.
475
    }
476
477
    /**
478
     * @param $data
479
     * @throws ConnectionException
480
     */
481
    protected function write(string $data) : void
482
    {
483
        $written = fwrite($this->socket, $data);
484
485
        if ($written < strlen($data)) {
486
            throw new ConnectionException(
487
                "Could only write $written out of " . strlen($data) . " bytes."
488
            );
489
        }
490
    }
491
492
    /**
493
     * @param int $len
494
     * @return string
495
     * @throws ConnectionException
496
     */
497
    protected function read(int $len) : string
498
    {
499
        $data = '';
500
        while (($dataLen = strlen($data)) < $len) {
501
            $buff = fread($this->socket, $len - $dataLen);
502
            if ($buff === false) {
503
                $metadata = stream_get_meta_data($this->socket);
504
                throw new ConnectionException(
505
                    'Broken frame, read ' . strlen($data) . ' of stated '
506
                    . $len . ' bytes.  Stream state: '
507
                    . json_encode($metadata)
508
                );
509
            }
510
            if ($buff === '') {
511
                $metadata = stream_get_meta_data($this->socket);
512
                throw new ConnectionException(
513
                    'Empty read; connection dead?  Stream state: ' . json_encode($metadata)
514
                );
515
            }
516
            $data .= $buff;
517
        }
518
        return $data;
519
    }
520
521
    /**
522
     * Helper to convert a binary to a string of '0' and '1'.
523
     *
524
     * @param $string
525
     * @return string
526
     */
527
    protected static function sprintB(string $string) : string
528
    {
529
        $return = '';
530
        $strLen = strlen($string);
531
        for ($i = 0; $i < $strLen; $i++) {
532
            $return .= sprintf('%08b', ord($string[$i]));
533
        }
534
        return $return;
535
    }
536
537
    /**
538
     * Sec-WebSocket-Key generator
539
     *
540
     * @return string   the 16 character length key
541
     * @throws \Exception
542
     */
543
    private function generateKey() : string
544
    {
545
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789';
546
        $key = '';
547
        $chLen = strlen($chars);
548
        for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) {
549
            $key .= $chars[random_int(0, $chLen - 1)];
550
        }
551
        return base64_encode($key);
552
    }
553
554
}
555