1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace WSSC\Components; |
4
|
|
|
|
5
|
|
|
use WSSC\Contracts\WscCommonsContract; |
6
|
|
|
use WSSC\Exceptions\BadOpcodeException; |
7
|
|
|
use WSSC\Exceptions\BadUriException; |
8
|
|
|
use WSSC\Exceptions\ConnectionException; |
9
|
|
|
|
10
|
|
|
class WscMain implements WscCommonsContract |
11
|
|
|
{ |
12
|
|
|
|
13
|
|
|
private $socket; |
14
|
|
|
private $isConnected = false; |
15
|
|
|
private $isClosing = false; |
16
|
|
|
private $lastOpcode; |
17
|
|
|
private $closeStatus; |
18
|
|
|
private $hugePayload; |
19
|
|
|
|
20
|
|
|
private static $opcodes = [ |
21
|
|
|
'continuation' => 0, |
22
|
|
|
'text' => 1, |
23
|
|
|
'binary' => 2, |
24
|
|
|
'close' => 8, |
25
|
|
|
'ping' => 9, |
26
|
|
|
'pong' => 10, |
27
|
|
|
]; |
28
|
|
|
|
29
|
|
|
protected $socketUrl = ''; |
30
|
|
|
protected $options = []; |
31
|
|
|
|
32
|
|
|
/** |
33
|
|
|
* @throws \InvalidArgumentException |
34
|
|
|
* @throws BadUriException |
35
|
|
|
* @throws ConnectionException |
36
|
|
|
* @throws \Exception |
37
|
|
|
*/ |
38
|
|
|
protected function connect() : void |
39
|
|
|
{ |
40
|
|
|
$urlParts = parse_url($this->socketUrl); |
41
|
|
|
$scheme = $urlParts['scheme']; |
42
|
|
|
$host = $urlParts['host']; |
43
|
|
|
$user = isset($urlParts['user']) ? $urlParts['user'] : ''; |
44
|
|
|
$pass = isset($urlParts['pass']) ? $urlParts['pass'] : ''; |
45
|
|
|
$port = isset($urlParts['port']) ? $urlParts['port'] : ($scheme === 'wss' ? 443 : 80); |
46
|
|
|
$path = isset($urlParts['path']) ? $urlParts['path'] : '/'; |
47
|
|
|
$query = isset($urlParts['query']) ? $urlParts['query'] : ''; |
48
|
|
|
$fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : ''; |
49
|
|
|
|
50
|
|
|
$path_with_query = $path; |
51
|
|
|
if (!empty($query)) { |
52
|
|
|
$path_with_query .= '?' . $query; |
53
|
|
|
} |
54
|
|
|
if (!empty($fragment)) { |
55
|
|
|
$path_with_query .= '#' . $fragment; |
56
|
|
|
} |
57
|
|
|
|
58
|
|
|
if (!in_array($scheme, ['ws', 'wss'])) { |
59
|
|
|
throw new BadUriException( |
60
|
|
|
"Url should have scheme ws or wss, not '$scheme' from URI '$this->socketUrl' ." |
61
|
|
|
); |
62
|
|
|
} |
63
|
|
|
|
64
|
|
|
$host_uri = ($scheme === 'wss' ? 'ssl' : 'tcp') . '://' . $host; |
65
|
|
|
|
66
|
|
|
// Set the stream context options if they're already set in the config |
67
|
|
|
if (isset($this->options['context'])) { |
68
|
|
|
// Suppress the error since we'll catch it below |
69
|
|
|
if (@get_resource_type($this->options['context']) === 'stream-context') { |
70
|
|
|
$context = $this->options['context']; |
71
|
|
|
} else { |
72
|
|
|
throw new \InvalidArgumentException( |
73
|
|
|
"Stream context in \$options['context'] isn't a valid context" |
74
|
|
|
); |
75
|
|
|
} |
76
|
|
|
} else { |
77
|
|
|
$context = stream_context_create(); |
78
|
|
|
} |
79
|
|
|
|
80
|
|
|
$this->socket = @stream_socket_client( |
81
|
|
|
$host_uri . ':' . $port, $errno, $errstr, $this->options['timeout'], STREAM_CLIENT_CONNECT, $context |
82
|
|
|
); |
83
|
|
|
|
84
|
|
|
if ($this->socket === false) { |
85
|
|
|
throw new ConnectionException( |
86
|
|
|
"Could not open socket to \"$host:$port\": $errstr ($errno)." |
87
|
|
|
); |
88
|
|
|
} |
89
|
|
|
|
90
|
|
|
// Set timeout on the stream as well. |
91
|
|
|
stream_set_timeout($this->socket, $this->options['timeout']); |
92
|
|
|
|
93
|
|
|
// Generate the WebSocket key. |
94
|
|
|
$key = $this->generateKey(); |
95
|
|
|
|
96
|
|
|
$headers = [ |
97
|
|
|
'Host' => $host . ':' . $port, |
98
|
|
|
'User-Agent' => 'websocket-client-php', |
99
|
|
|
'Connection' => 'Upgrade', |
100
|
|
|
'Upgrade' => 'WebSocket', |
101
|
|
|
'Sec-WebSocket-Key' => $key, |
102
|
|
|
'Sec-Websocket-Version' => '13', |
103
|
|
|
]; |
104
|
|
|
|
105
|
|
|
// Handle basic authentication. |
106
|
|
|
if ($user || $pass) { |
107
|
|
|
$headers['authorization'] = 'Basic ' . base64_encode($user . ':' . $pass) . "\r\n"; |
108
|
|
|
} |
109
|
|
|
|
110
|
|
|
// Add and override with headers from options. |
111
|
|
|
if (isset($this->options['headers'])) { |
112
|
|
|
$headers = array_merge($headers, $this->options['headers']); |
113
|
|
|
} |
114
|
|
|
|
115
|
|
|
$header = 'GET ' . $path_with_query . " HTTP/1.1\r\n" |
116
|
|
|
. implode( |
117
|
|
|
"\r\n", array_map( |
118
|
|
|
function ($key, $value) { |
119
|
|
|
return "$key: $value"; |
120
|
|
|
}, array_keys($headers), $headers |
121
|
|
|
) |
122
|
|
|
) |
123
|
|
|
. "\r\n\r\n"; |
124
|
|
|
// Send headers. |
125
|
|
|
$this->write($header); |
126
|
|
|
// Get server response header |
127
|
|
|
$response = stream_get_line($this->socket, self::DEFAULT_RESPONSE_HEADER, "\r\n\r\n"); |
128
|
|
|
/// @todo Handle version switching |
129
|
|
|
// Validate response. |
130
|
|
|
if (!preg_match(self::SEC_WEBSOCKET_ACCEPT_PTTRN, $response, $matches)) { |
131
|
|
|
$address = $scheme . '://' . $host . $path_with_query; |
132
|
|
|
throw new ConnectionException( |
133
|
|
|
"Connection to '{$address}' failed: Server sent invalid upgrade response:\n" |
134
|
|
|
. $response |
135
|
|
|
); |
136
|
|
|
} |
137
|
|
|
|
138
|
|
|
$keyAccept = trim($matches[1]); |
139
|
|
|
$expectedResonse = base64_encode(pack('H*', sha1($key . self::SERVER_KEY_ACCEPT))); |
140
|
|
|
|
141
|
|
|
if ($keyAccept !== $expectedResonse) { |
142
|
|
|
throw new ConnectionException('Server sent bad upgrade response.'); |
143
|
|
|
} |
144
|
|
|
|
145
|
|
|
$this->isConnected = true; |
146
|
|
|
} |
147
|
|
|
|
148
|
|
|
public function getLastOpcode() |
149
|
|
|
{ |
150
|
|
|
return $this->lastOpcode; |
151
|
|
|
} |
152
|
|
|
|
153
|
|
|
public function getCloseStatus() |
154
|
|
|
{ |
155
|
|
|
return $this->closeStatus; |
156
|
|
|
} |
157
|
|
|
|
158
|
|
|
public function isConnected() |
159
|
|
|
{ |
160
|
|
|
return $this->isConnected; |
161
|
|
|
} |
162
|
|
|
|
163
|
|
|
/** |
164
|
|
|
* @param int $timeout |
165
|
|
|
* @param null $microSecs |
166
|
|
|
*/ |
167
|
|
|
public function setTimeout(int $timeout, $microSecs = null) |
168
|
|
|
{ |
169
|
|
|
$this->options['timeout'] = $timeout; |
170
|
|
|
|
171
|
|
|
if ($this->socket && get_resource_type($this->socket) === 'stream') { |
172
|
|
|
stream_set_timeout($this->socket, $timeout, $microSecs); |
173
|
|
|
} |
174
|
|
|
} |
175
|
|
|
|
176
|
|
|
public function setFragmentSize($fragment_size) |
177
|
|
|
{ |
178
|
|
|
$this->options['fragment_size'] = $fragment_size; |
179
|
|
|
return $this; |
180
|
|
|
} |
181
|
|
|
|
182
|
|
|
public function getFragmentSize() |
183
|
|
|
{ |
184
|
|
|
return $this->options['fragment_size']; |
185
|
|
|
} |
186
|
|
|
|
187
|
|
|
public function send($payload, $opcode = 'text', $masked = true) |
188
|
|
|
{ |
189
|
|
|
if (!$this->isConnected) { |
190
|
|
|
$this->connect(); |
191
|
|
|
} |
192
|
|
|
if (array_key_exists($opcode, self::$opcodes) === false) { |
193
|
|
|
throw new BadOpcodeException("Bad opcode '$opcode'. Try 'text' or 'binary'."); |
194
|
|
|
} |
195
|
|
|
echo $payload; |
196
|
|
|
// record the length of the payload |
197
|
|
|
$payload_length = strlen($payload); |
198
|
|
|
|
199
|
|
|
$fragment_cursor = 0; |
200
|
|
|
// while we have data to send |
201
|
|
|
while ($payload_length > $fragment_cursor) { |
202
|
|
|
// get a fragment of the payload |
203
|
|
|
$sub_payload = substr($payload, $fragment_cursor, $this->options['fragment_size']); |
204
|
|
|
|
205
|
|
|
// advance the cursor |
206
|
|
|
$fragment_cursor += $this->options['fragment_size']; |
207
|
|
|
|
208
|
|
|
// is this the final fragment to send? |
209
|
|
|
$final = $payload_length <= $fragment_cursor; |
210
|
|
|
|
211
|
|
|
// send the fragment |
212
|
|
|
$this->sendFragment($final, $sub_payload, $opcode, $masked); |
213
|
|
|
|
214
|
|
|
// all fragments after the first will be marked a continuation |
215
|
|
|
$opcode = 'continuation'; |
216
|
|
|
} |
217
|
|
|
} |
218
|
|
|
|
219
|
|
|
/** |
220
|
|
|
* @param $final |
221
|
|
|
* @param $payload |
222
|
|
|
* @param $opcode |
223
|
|
|
* @param $masked |
224
|
|
|
* @throws ConnectionException |
225
|
|
|
* @throws \Exception |
226
|
|
|
*/ |
227
|
|
|
protected function sendFragment($final, $payload, $opcode, $masked) |
228
|
|
|
{ |
229
|
|
|
// Binary string for header. |
230
|
|
|
$frameHeadBin = ''; |
231
|
|
|
// Write FIN, final fragment bit. |
232
|
|
|
$frameHeadBin .= (bool)$final ? '1' : '0'; |
233
|
|
|
// RSV 1, 2, & 3 false and unused. |
|
|
|
|
234
|
|
|
$frameHeadBin .= '000'; |
235
|
|
|
// Opcode rest of the byte. |
236
|
|
|
$frameHeadBin .= sprintf('%04b', self::$opcodes[$opcode]); |
237
|
|
|
// Use masking? |
238
|
|
|
$frameHeadBin .= $masked ? '1' : '0'; |
239
|
|
|
|
240
|
|
|
// 7 bits of payload length... |
241
|
|
|
$payloadLen = strlen($payload); |
242
|
|
|
if ($payloadLen > self::MAX_BYTES_READ) { |
243
|
|
|
$frameHeadBin .= decbin(self::MASK_127); |
244
|
|
|
$frameHeadBin .= sprintf('%064b', $payloadLen); |
245
|
|
|
} else if ($payloadLen > self::MASK_125) { |
246
|
|
|
$frameHeadBin .= decbin(self::MASK_126); |
247
|
|
|
$frameHeadBin .= sprintf('%016b', $payloadLen); |
248
|
|
|
} else { |
249
|
|
|
$frameHeadBin .= sprintf('%07b', $payloadLen); |
250
|
|
|
} |
251
|
|
|
|
252
|
|
|
$frame = ''; |
253
|
|
|
|
254
|
|
|
// Write frame head to frame. |
255
|
|
|
foreach (str_split($frameHeadBin, 8) as $binstr) { |
256
|
|
|
$frame .= chr(bindec($binstr)); |
257
|
|
|
} |
258
|
|
|
// Handle masking |
259
|
|
|
if ($masked) { |
260
|
|
|
// generate a random mask: |
261
|
|
|
$mask = ''; |
262
|
|
View Code Duplication |
for ($i = 0; $i < 4; $i++) { |
|
|
|
|
263
|
|
|
$mask .= chr(random_int(0, 255)); |
264
|
|
|
} |
265
|
|
|
$frame .= $mask; |
266
|
|
|
} |
267
|
|
|
|
268
|
|
|
// Append payload to frame: |
269
|
|
View Code Duplication |
for ($i = 0; $i < $payloadLen; $i++) { |
|
|
|
|
270
|
|
|
$frame .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i]; |
|
|
|
|
271
|
|
|
} |
272
|
|
|
|
273
|
|
|
$this->write($frame); |
274
|
|
|
} |
275
|
|
|
|
276
|
|
|
public function receive() |
277
|
|
|
{ |
278
|
|
|
if (!$this->isConnected) { |
279
|
|
|
$this->connect(); |
280
|
|
|
} |
281
|
|
|
$this->hugePayload = ''; |
282
|
|
|
|
283
|
|
|
$response = NULL; |
284
|
|
|
while (NULL === $response) { |
285
|
|
|
$response = $this->receiveFragment(); |
286
|
|
|
} |
287
|
|
|
return $response; |
288
|
|
|
} |
289
|
|
|
|
290
|
|
|
protected function receiveFragment() |
291
|
|
|
{ |
292
|
|
|
// Just read the main fragment information first. |
293
|
|
|
$data = $this->read(2); |
294
|
|
|
|
295
|
|
|
// Is this the final fragment? // Bit 0 in byte 0 |
296
|
|
|
/// @todo Handle huge payloads with multiple fragments. |
297
|
|
|
$final = (bool)(ord($data[0]) & 1 << 7); |
298
|
|
|
|
299
|
|
|
// Should be unused, and must be false… // Bits 1, 2, & 3 |
300
|
|
|
// $rsv1 = (boolean) (ord($data[0]) & 1 << 6); |
|
|
|
|
301
|
|
|
// $rsv2 = (boolean) (ord($data[0]) & 1 << 5); |
|
|
|
|
302
|
|
|
// $rsv3 = (boolean) (ord($data[0]) & 1 << 4); |
|
|
|
|
303
|
|
|
// Parse opcode |
304
|
|
|
$opcode_int = ord($data[0]) & 31; // Bits 4-7 |
305
|
|
|
$opcode_ints = array_flip(self::$opcodes); |
306
|
|
|
if (!array_key_exists($opcode_int, $opcode_ints)) { |
307
|
|
|
throw new ConnectionException("Bad opcode in websocket frame: $opcode_int"); |
308
|
|
|
} |
309
|
|
|
$opcode = $opcode_ints[$opcode_int]; |
310
|
|
|
|
311
|
|
|
// record the opcode if we are not receiving a continutation fragment |
312
|
|
|
if ($opcode !== 'continuation') { |
313
|
|
|
$this->lastOpcode = $opcode; |
314
|
|
|
} |
315
|
|
|
|
316
|
|
|
// Masking? |
317
|
|
|
$mask = (bool)(ord($data[1]) >> 7); // Bit 0 in byte 1 |
318
|
|
|
|
319
|
|
|
$payload = ''; |
320
|
|
|
|
321
|
|
|
// Payload length |
322
|
|
|
$payload_length = (int)ord($data[1]) & self::MASK_127; // Bits 1-7 in byte 1 |
323
|
|
|
if ($payload_length > self::MASK_125) { |
324
|
|
|
if ($payload_length === self::MASK_126) { |
325
|
|
|
$data = $this->read(2); // 126: Payload is a 16-bit unsigned int |
326
|
|
|
} else { |
327
|
|
|
$data = $this->read(8); // 127: Payload is a 64-bit unsigned int |
328
|
|
|
} |
329
|
|
|
$payload_length = bindec(self::sprintB($data)); |
330
|
|
|
} |
331
|
|
|
|
332
|
|
|
// Get masking key. |
333
|
|
|
if ($mask) { |
334
|
|
|
$masking_key = $this->read(4); |
335
|
|
|
} |
336
|
|
|
// Get the actual payload, if any (might not be for e.g. close frames. |
337
|
|
|
if ($payload_length > 0) { |
338
|
|
|
$data = $this->read($payload_length); |
339
|
|
|
|
340
|
|
|
if ($mask) { |
341
|
|
|
// Unmask payload. |
342
|
|
|
for ($i = 0; $i < $payload_length; $i++) { |
343
|
|
|
$payload .= ($data[$i] ^ $masking_key[$i % 4]); |
|
|
|
|
344
|
|
|
} |
345
|
|
|
} else { |
346
|
|
|
$payload = $data; |
347
|
|
|
} |
348
|
|
|
} |
349
|
|
|
|
350
|
|
|
if ($opcode === 'close') { |
351
|
|
|
// Get the close status. |
352
|
|
|
if ($payload_length >= 2) { |
353
|
|
|
$status_bin = $payload[0] . $payload[1]; |
354
|
|
|
$status = bindec(sprintf('%08b%08b', ord($payload[0]), ord($payload[1]))); |
355
|
|
|
$this->closeStatus = $status; |
356
|
|
|
$payload = substr($payload, 2); |
357
|
|
|
|
358
|
|
|
if (!$this->isClosing) { |
359
|
|
|
$this->send($status_bin . 'Close acknowledged: ' . $status, 'close'); // Respond. |
360
|
|
|
} |
361
|
|
|
} |
362
|
|
|
|
363
|
|
|
if ($this->isClosing) { |
364
|
|
|
$this->isClosing = false; // A close response, all done. |
365
|
|
|
} |
366
|
|
|
|
367
|
|
|
fclose($this->socket); |
368
|
|
|
$this->isConnected = false; |
369
|
|
|
} |
370
|
|
|
|
371
|
|
|
if (!$final) { |
372
|
|
|
$this->hugePayload .= $payload; |
373
|
|
|
return NULL; |
374
|
|
|
} // this is the last fragment, and we are processing a huge_payload |
375
|
|
|
|
376
|
|
|
if ($this->hugePayload) { |
377
|
|
|
$payload = $this->hugePayload .= $payload; |
378
|
|
|
$this->hugePayload = NULL; |
379
|
|
|
} |
380
|
|
|
|
381
|
|
|
return $payload; |
382
|
|
|
} |
383
|
|
|
|
384
|
|
|
/** |
385
|
|
|
* Tell the socket to close. |
386
|
|
|
* |
387
|
|
|
* @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4 |
388
|
|
|
* @param string $message A closing message, max 125 bytes. |
389
|
|
|
* @return bool|null|string |
390
|
|
|
* @throws BadOpcodeException |
391
|
|
|
*/ |
392
|
|
|
public function close(int $status = 1000, string $message = 'ttfn') |
393
|
|
|
{ |
394
|
|
|
$statusBin = sprintf('%016b', $status); |
395
|
|
|
$status_str = ''; |
396
|
|
|
foreach (str_split($statusBin, 8) as $binstr) { |
397
|
|
|
$status_str .= chr(bindec($binstr)); |
398
|
|
|
} |
399
|
|
|
$this->send($status_str . $message, 'close', true); |
400
|
|
|
$this->isClosing = true; |
401
|
|
|
return $this->receive(); // Receiving a close frame will close the socket now. |
402
|
|
|
} |
403
|
|
|
|
404
|
|
|
/** |
405
|
|
|
* @param $data |
406
|
|
|
* @throws ConnectionException |
407
|
|
|
*/ |
408
|
|
|
protected function write(string $data) : void |
409
|
|
|
{ |
410
|
|
|
$written = fwrite($this->socket, $data); |
411
|
|
|
|
412
|
|
|
if ($written < strlen($data)) { |
413
|
|
|
throw new ConnectionException( |
414
|
|
|
"Could only write $written out of " . strlen($data) . " bytes." |
415
|
|
|
); |
416
|
|
|
} |
417
|
|
|
} |
418
|
|
|
|
419
|
|
|
/** |
420
|
|
|
* @param int $len |
421
|
|
|
* @return string |
422
|
|
|
* @throws ConnectionException |
423
|
|
|
*/ |
424
|
|
|
protected function read(int $len) : string |
425
|
|
|
{ |
426
|
|
|
$data = ''; |
427
|
|
|
while (($dataLen = strlen($data)) < $len) { |
428
|
|
|
$buff = fread($this->socket, $len - $dataLen); |
429
|
|
|
if ($buff === false) { |
430
|
|
|
$metadata = stream_get_meta_data($this->socket); |
431
|
|
|
throw new ConnectionException( |
432
|
|
|
'Broken frame, read ' . strlen($data) . ' of stated ' |
433
|
|
|
. $len . ' bytes. Stream state: ' |
434
|
|
|
. json_encode($metadata) |
435
|
|
|
); |
436
|
|
|
} |
437
|
|
|
if ($buff === '') { |
438
|
|
|
$metadata = stream_get_meta_data($this->socket); |
439
|
|
|
throw new ConnectionException( |
440
|
|
|
'Empty read; connection dead? Stream state: ' . json_encode($metadata) |
441
|
|
|
); |
442
|
|
|
} |
443
|
|
|
$data .= $buff; |
444
|
|
|
} |
445
|
|
|
return $data; |
446
|
|
|
} |
447
|
|
|
|
448
|
|
|
/** |
449
|
|
|
* Helper to convert a binary to a string of '0' and '1'. |
450
|
|
|
* |
451
|
|
|
* @param $string |
452
|
|
|
* @return string |
453
|
|
|
*/ |
454
|
|
|
protected static function sprintB(string $string) : string |
455
|
|
|
{ |
456
|
|
|
$return = ''; |
457
|
|
|
$strLen = strlen($string); |
458
|
|
|
for ($i = 0; $i < $strLen; $i++) { |
459
|
|
|
$return .= sprintf('%08b', ord($string[$i])); |
460
|
|
|
} |
461
|
|
|
return $return; |
462
|
|
|
} |
463
|
|
|
|
464
|
|
|
/** |
465
|
|
|
* Sec-WebSocket-Key generator |
466
|
|
|
* |
467
|
|
|
* @return string the 16 character length key |
468
|
|
|
* @throws \Exception |
469
|
|
|
*/ |
470
|
|
|
private function generateKey() : string |
471
|
|
|
{ |
472
|
|
|
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789'; |
473
|
|
|
$key = ''; |
474
|
|
|
$chLen = strlen($chars); |
475
|
|
|
for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) { |
476
|
|
|
$key .= $chars[random_int(0, $chLen - 1)]; |
477
|
|
|
} |
478
|
|
|
return base64_encode($key); |
479
|
|
|
} |
480
|
|
|
|
481
|
|
|
} |
482
|
|
|
|
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.