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
|
|
|
/** |
12
|
|
|
* Class WscMain |
13
|
|
|
* |
14
|
|
|
* @package WSSC\Components |
15
|
|
|
* |
16
|
|
|
* @property ClientConfig config |
17
|
|
|
*/ |
18
|
|
|
class WscMain implements WscCommonsContract |
19
|
|
|
{ |
20
|
|
|
use WSClientTrait; |
21
|
|
|
|
22
|
|
|
private $socket; |
23
|
|
|
private $isConnected = false; |
24
|
|
|
private $isClosing = false; |
25
|
|
|
private $lastOpcode; |
26
|
|
|
private $closeStatus; |
27
|
|
|
private $hugePayload; |
28
|
|
|
|
29
|
|
|
private static $opcodes = [ |
30
|
|
|
CommonsContract::EVENT_TYPE_CONTINUATION => 0, |
31
|
|
|
CommonsContract::EVENT_TYPE_TEXT => 1, |
32
|
|
|
CommonsContract::EVENT_TYPE_BINARY => 2, |
33
|
|
|
CommonsContract::EVENT_TYPE_CLOSE => 8, |
34
|
|
|
CommonsContract::EVENT_TYPE_PING => 9, |
35
|
|
|
CommonsContract::EVENT_TYPE_PONG => 10, |
36
|
|
|
]; |
37
|
|
|
|
38
|
|
|
protected $socketUrl = ''; |
39
|
|
|
protected $config; |
40
|
|
|
|
41
|
|
|
/** |
42
|
|
|
* @throws \InvalidArgumentException |
43
|
|
|
* @throws BadUriException |
44
|
|
|
* @throws ConnectionException |
45
|
|
|
* @throws \Exception |
46
|
|
|
*/ |
47
|
|
|
protected function connect() |
48
|
|
|
{ |
49
|
|
|
$urlParts = parse_url($this->socketUrl); |
50
|
|
|
|
51
|
|
|
$this->config->setScheme($urlParts['scheme']); |
52
|
|
|
$this->config->setHost($urlParts['host']); |
53
|
|
|
$this->config->setUser($urlParts); |
|
|
|
|
54
|
|
|
$this->config->setPassword($urlParts); |
|
|
|
|
55
|
|
|
$this->config->setPort($urlParts); |
|
|
|
|
56
|
|
|
|
57
|
|
|
$pathWithQuery = $this->getPathWithQuery($urlParts); |
58
|
|
|
$hostUri = $this->getHostUri($this->config); |
59
|
|
|
|
60
|
|
|
// Set the stream context options if they're already set in the config |
61
|
|
|
$context = $this->getStreamContext(); |
62
|
|
|
|
63
|
|
|
|
64
|
|
|
if ($this->config->hasProxy()) { |
65
|
|
|
$this->socket = $this->proxy($this->config); |
66
|
|
|
} else { |
67
|
|
|
$this->socket = @stream_socket_client( |
68
|
|
|
$hostUri . ':' . $this->config->getPort(), |
69
|
|
|
$errno, |
70
|
|
|
$errstr, |
71
|
|
|
$this->config->getTimeout(), |
72
|
|
|
STREAM_CLIENT_CONNECT, |
73
|
|
|
$context |
74
|
|
|
); |
75
|
|
|
} |
76
|
|
|
|
77
|
|
|
if ($this->socket === false) { |
78
|
|
|
throw new ConnectionException( |
79
|
|
|
"Could not open socket to \"{$this->config->getHost()}:{$this->config->getPort()}\": $errstr ($errno).", |
80
|
|
|
CommonsContract::CLIENT_COULD_NOT_OPEN_SOCKET |
81
|
|
|
); |
82
|
|
|
} |
83
|
|
|
|
84
|
|
|
// Set timeout on the stream as well. |
85
|
|
|
stream_set_timeout($this->socket, $this->config->getTimeout()); |
86
|
|
|
|
87
|
|
|
// Generate the WebSocket key. |
88
|
|
|
$key = $this->generateKey(); |
89
|
|
|
$headers = [ |
90
|
|
|
'Host' => $this->config->getHost() . ':' . $this->config->getPort(), |
91
|
|
|
'User-Agent' => 'websocket-client-php', |
92
|
|
|
'Connection' => 'Upgrade', |
93
|
|
|
'Upgrade' => 'WebSocket', |
94
|
|
|
'Sec-WebSocket-Key' => $key, |
95
|
|
|
'Sec-Websocket-Version' => '13', |
96
|
|
|
]; |
97
|
|
|
|
98
|
|
|
// Handle basic authentication. |
99
|
|
|
if ($this->config->getUser() || $this->config->getPassword()) { |
100
|
|
|
$headers['authorization'] = 'Basic ' . base64_encode($this->config->getUser() . ':' . $this->config->getPassword()) . "\r\n"; |
101
|
|
|
} |
102
|
|
|
|
103
|
|
|
// Add and override with headers from options. |
104
|
|
|
if (!empty($this->config->getHeaders())) { |
105
|
|
|
$headers = array_merge($headers, $this->config->getHeaders()); |
106
|
|
|
} |
107
|
|
|
|
108
|
|
|
$header = $this->getHeaders($pathWithQuery, $headers); |
109
|
|
|
|
110
|
|
|
// Send headers. |
111
|
|
|
$this->write($header); |
112
|
|
|
|
113
|
|
|
// Get server response header |
114
|
|
|
// @todo Handle version switching |
115
|
|
|
$this->validateResponse($this->config, $pathWithQuery, $key); |
116
|
|
|
$this->isConnected = true; |
117
|
|
|
} |
118
|
|
|
|
119
|
|
|
|
120
|
|
|
/** |
121
|
|
|
* Init a proxy connection |
122
|
|
|
* |
123
|
|
|
* @param ClientConfig $config |
124
|
|
|
* @return bool|resource |
125
|
|
|
* @throws \InvalidArgumentException |
126
|
|
|
* @throws \WSSC\Exceptions\ConnectionException |
127
|
|
|
*/ |
128
|
|
|
private function proxy(ClientConfig $config) |
129
|
|
|
{ |
130
|
|
|
$sock = @stream_socket_client( |
131
|
|
|
WscCommonsContract::TCP_SCHEME . $config->getProxyIp() . ':' . $config->getProxyPort(), |
132
|
|
|
$errno, |
133
|
|
|
$errstr, |
134
|
|
|
$this->config->getTimeout(), |
135
|
|
|
STREAM_CLIENT_CONNECT, |
136
|
|
|
$this->getStreamContext() |
137
|
|
|
); |
138
|
|
|
|
139
|
|
|
$write = "CONNECT {$config->getHost()} HTTP/1.1\r\n"; |
140
|
|
|
$auth = $config->getProxyAuth(); |
141
|
|
|
if ($auth !== NULL) { |
142
|
|
|
$write .= "Proxy-Authorization: Basic {$auth}\r\n"; |
143
|
|
|
} |
144
|
|
|
$write .= "\r\n"; |
145
|
|
|
fwrite($sock, $write); |
146
|
|
|
$resp = fread($sock, 1024); |
147
|
|
|
|
148
|
|
|
if (preg_match('/^HTTP\/\d\.\d 200/', $resp) === 1) { |
149
|
|
|
return $sock; |
150
|
|
|
} |
151
|
|
|
|
152
|
|
|
throw new ConnectionException('Failed to connect to the host via proxy'); |
153
|
|
|
} |
154
|
|
|
|
155
|
|
|
|
156
|
|
|
/** |
157
|
|
|
* @return mixed|resource |
158
|
|
|
* @throws \InvalidArgumentException |
159
|
|
|
*/ |
160
|
|
|
private function getStreamContext() |
161
|
|
|
{ |
162
|
|
|
if ($this->config->getContext() !== null) { |
163
|
|
|
// Suppress the error since we'll catch it below |
164
|
|
|
if (@get_resource_type($this->config->getContext()) === 'stream-context') { |
165
|
|
|
return $this->config->getContext(); |
166
|
|
|
} |
167
|
|
|
|
168
|
|
|
throw new \InvalidArgumentException( |
169
|
|
|
'Stream context is invalid', |
170
|
|
|
CommonsContract::CLIENT_INVALID_STREAM_CONTEXT |
171
|
|
|
); |
172
|
|
|
} |
173
|
|
|
|
174
|
|
|
return stream_context_create($this->config->getContextOptions()); |
175
|
|
|
} |
176
|
|
|
|
177
|
|
|
/** |
178
|
|
|
* @param mixed $urlParts |
179
|
|
|
* @return string |
180
|
|
|
*/ |
181
|
|
|
private function getPathWithQuery($urlParts): string |
182
|
|
|
{ |
183
|
|
|
$path = isset($urlParts['path']) ? $urlParts['path'] : '/'; |
184
|
|
|
$query = isset($urlParts['query']) ? $urlParts['query'] : ''; |
185
|
|
|
$fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : ''; |
186
|
|
|
$pathWithQuery = $path; |
187
|
|
|
if (!empty($query)) { |
188
|
|
|
$pathWithQuery .= '?' . $query; |
189
|
|
|
} |
190
|
|
|
if (!empty($fragment)) { |
191
|
|
|
$pathWithQuery .= '#' . $fragment; |
192
|
|
|
} |
193
|
|
|
|
194
|
|
|
return $pathWithQuery; |
195
|
|
|
} |
196
|
|
|
|
197
|
|
|
/** |
198
|
|
|
* @param string $pathWithQuery |
199
|
|
|
* @param array $headers |
200
|
|
|
* @return string |
201
|
|
|
*/ |
202
|
|
|
private function getHeaders(string $pathWithQuery, array $headers): string |
203
|
|
|
{ |
204
|
|
|
return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n" |
205
|
|
|
. implode( |
206
|
|
|
"\r\n", |
207
|
|
|
array_map( |
208
|
|
|
function ($key, $value) { |
209
|
|
|
return "$key: $value"; |
210
|
|
|
}, |
211
|
|
|
array_keys($headers), |
212
|
|
|
$headers |
213
|
|
|
) |
214
|
|
|
) |
215
|
|
|
. "\r\n\r\n"; |
216
|
|
|
} |
217
|
|
|
|
218
|
|
|
/** |
219
|
|
|
* @return string |
220
|
|
|
*/ |
221
|
|
|
public function getLastOpcode(): string |
222
|
|
|
{ |
223
|
|
|
return $this->lastOpcode; |
224
|
|
|
} |
225
|
|
|
|
226
|
|
|
/** |
227
|
|
|
* @return int |
228
|
|
|
*/ |
229
|
|
|
public function getCloseStatus(): int |
230
|
|
|
{ |
231
|
|
|
return $this->closeStatus; |
232
|
|
|
} |
233
|
|
|
|
234
|
|
|
/** |
235
|
|
|
* @return bool |
236
|
|
|
*/ |
237
|
|
|
public function isConnected(): bool |
238
|
|
|
{ |
239
|
|
|
return $this->isConnected; |
240
|
|
|
} |
241
|
|
|
|
242
|
|
|
/** |
243
|
|
|
* @param int $timeout |
244
|
|
|
* @param null $microSecs |
245
|
|
|
* @return WscMain |
246
|
|
|
*/ |
247
|
|
|
public function setTimeout(int $timeout, $microSecs = null): WscMain |
248
|
|
|
{ |
249
|
|
|
$this->config->setTimeout($timeout); |
250
|
|
|
if ($this->socket && get_resource_type($this->socket) === 'stream') { |
251
|
|
|
stream_set_timeout($this->socket, $timeout, $microSecs); |
252
|
|
|
} |
253
|
|
|
|
254
|
|
|
return $this; |
255
|
|
|
} |
256
|
|
|
|
257
|
|
|
/** |
258
|
|
|
* Sends message to opened socket connection client->server |
259
|
|
|
* |
260
|
|
|
* @param $payload |
261
|
|
|
* @param string $opcode |
262
|
|
|
* @throws \InvalidArgumentException |
263
|
|
|
* @throws BadOpcodeException |
264
|
|
|
* @throws BadUriException |
265
|
|
|
* @throws ConnectionException |
266
|
|
|
* @throws \Exception |
267
|
|
|
*/ |
268
|
|
|
public function send($payload, $opcode = CommonsContract::EVENT_TYPE_TEXT) |
269
|
|
|
{ |
270
|
|
|
if (!$this->isConnected) { |
271
|
|
|
$this->connect(); |
272
|
|
|
} |
273
|
|
|
if (array_key_exists($opcode, self::$opcodes) === false) { |
274
|
|
|
throw new BadOpcodeException( |
275
|
|
|
"Bad opcode '$opcode'. Try 'text' or 'binary'.", |
276
|
|
|
CommonsContract::CLIENT_BAD_OPCODE |
277
|
|
|
); |
278
|
|
|
} |
279
|
|
|
// record the length of the payload |
280
|
|
|
$payloadLength = strlen($payload); |
281
|
|
|
|
282
|
|
|
$fragmentCursor = 0; |
283
|
|
|
// while we have data to send |
284
|
|
|
while ($payloadLength > $fragmentCursor) { |
285
|
|
|
// get a fragment of the payload |
286
|
|
|
$subPayload = substr($payload, $fragmentCursor, $this->config->getFragmentSize()); |
287
|
|
|
|
288
|
|
|
// advance the cursor |
289
|
|
|
$fragmentCursor += $this->config->getFragmentSize(); |
290
|
|
|
|
291
|
|
|
// is this the final fragment to send? |
292
|
|
|
$final = $payloadLength <= $fragmentCursor; |
293
|
|
|
|
294
|
|
|
// send the fragment |
295
|
|
|
$this->sendFragment($final, $subPayload, $opcode, true); |
296
|
|
|
|
297
|
|
|
// all fragments after the first will be marked a continuation |
298
|
|
|
$opcode = 'continuation'; |
299
|
|
|
} |
300
|
|
|
} |
301
|
|
|
|
302
|
|
|
/** |
303
|
|
|
* Receives message client<-server |
304
|
|
|
* |
305
|
|
|
* @return null|string |
306
|
|
|
* @throws \InvalidArgumentException |
307
|
|
|
* @throws BadOpcodeException |
308
|
|
|
* @throws BadUriException |
309
|
|
|
* @throws ConnectionException |
310
|
|
|
* @throws \Exception |
311
|
|
|
*/ |
312
|
|
|
public function receive() |
313
|
|
|
{ |
314
|
|
|
if (!$this->isConnected) { |
315
|
|
|
$this->connect(); |
316
|
|
|
} |
317
|
|
|
$this->hugePayload = ''; |
318
|
|
|
|
319
|
|
|
$response = null; |
320
|
|
|
while ($response === null) { |
321
|
|
|
$response = $this->receiveFragment(); |
322
|
|
|
} |
323
|
|
|
|
324
|
|
|
return $response; |
325
|
|
|
} |
326
|
|
|
|
327
|
|
|
/** |
328
|
|
|
* Tell the socket to close. |
329
|
|
|
* |
330
|
|
|
* @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4 |
331
|
|
|
* @param string $message A closing message, max 125 bytes. |
332
|
|
|
* @return bool|null|string |
333
|
|
|
* @throws \InvalidArgumentException |
334
|
|
|
* @throws BadOpcodeException |
335
|
|
|
* @throws BadUriException |
336
|
|
|
* @throws ConnectionException |
337
|
|
|
* @throws \Exception |
338
|
|
|
*/ |
339
|
|
|
public function close(int $status = 1000, string $message = 'ttfn') |
340
|
|
|
{ |
341
|
|
|
$statusBin = sprintf('%016b', $status); |
342
|
|
|
$status_str = ''; |
343
|
|
|
|
344
|
|
|
foreach (str_split($statusBin, 8) as $binstr) { |
345
|
|
|
$status_str .= chr(bindec($binstr)); |
346
|
|
|
} |
347
|
|
|
|
348
|
|
|
$this->send($status_str . $message, CommonsContract::EVENT_TYPE_CLOSE); |
349
|
|
|
$this->isClosing = true; |
350
|
|
|
|
351
|
|
|
return $this->receive(); // Receiving a close frame will close the socket now. |
352
|
|
|
} |
353
|
|
|
|
354
|
|
|
/** |
355
|
|
|
* @param $data |
356
|
|
|
* @throws ConnectionException |
357
|
|
|
*/ |
358
|
|
|
protected function write(string $data) |
359
|
|
|
{ |
360
|
|
|
$written = fwrite($this->socket, $data); |
361
|
|
|
|
362
|
|
|
if ($written < strlen($data)) { |
363
|
|
|
throw new ConnectionException( |
364
|
|
|
"Could only write $written out of " . strlen($data) . ' bytes.', |
365
|
|
|
CommonsContract::CLIENT_COULD_ONLY_WRITE_LESS |
366
|
|
|
); |
367
|
|
|
} |
368
|
|
|
} |
369
|
|
|
|
370
|
|
|
/** |
371
|
|
|
* @param int $len |
372
|
|
|
* @return string |
373
|
|
|
* @throws ConnectionException |
374
|
|
|
*/ |
375
|
|
|
protected function read(int $len): string |
376
|
|
|
{ |
377
|
|
|
$data = ''; |
378
|
|
|
while (($dataLen = strlen($data)) < $len) { |
379
|
|
|
$buff = fread($this->socket, $len - $dataLen); |
380
|
|
|
|
381
|
|
|
if ($buff === false) { |
382
|
|
|
$metadata = stream_get_meta_data($this->socket); |
383
|
|
|
throw new ConnectionException( |
384
|
|
|
'Broken frame, read ' . strlen($data) . ' of stated ' |
385
|
|
|
. $len . ' bytes. Stream state: ' |
386
|
|
|
. json_encode($metadata), |
387
|
|
|
CommonsContract::CLIENT_BROKEN_FRAME |
388
|
|
|
); |
389
|
|
|
} |
390
|
|
|
|
391
|
|
|
if ($buff === '') { |
392
|
|
|
$metadata = stream_get_meta_data($this->socket); |
393
|
|
|
throw new ConnectionException( |
394
|
|
|
'Empty read; connection dead? Stream state: ' . json_encode($metadata), |
395
|
|
|
CommonsContract::CLIENT_EMPTY_READ |
396
|
|
|
); |
397
|
|
|
} |
398
|
|
|
$data .= $buff; |
399
|
|
|
} |
400
|
|
|
|
401
|
|
|
return $data; |
402
|
|
|
} |
403
|
|
|
|
404
|
|
|
/** |
405
|
|
|
* Helper to convert a binary to a string of '0' and '1'. |
406
|
|
|
* |
407
|
|
|
* @param $string |
408
|
|
|
* @return string |
409
|
|
|
*/ |
410
|
|
|
protected static function sprintB(string $string): string |
411
|
|
|
{ |
412
|
|
|
$return = ''; |
413
|
|
|
$strLen = strlen($string); |
414
|
|
|
for ($i = 0; $i < $strLen; $i++) { |
415
|
|
|
$return .= sprintf('%08b', ord($string[$i])); |
416
|
|
|
} |
417
|
|
|
|
418
|
|
|
return $return; |
419
|
|
|
} |
420
|
|
|
|
421
|
|
|
/** |
422
|
|
|
* Sec-WebSocket-Key generator |
423
|
|
|
* |
424
|
|
|
* @return string the 16 character length key |
425
|
|
|
* @throws \Exception |
426
|
|
|
*/ |
427
|
|
|
private function generateKey(): string |
428
|
|
|
{ |
429
|
|
|
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789'; |
430
|
|
|
$key = ''; |
431
|
|
|
$chLen = strlen($chars); |
432
|
|
|
for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) { |
433
|
|
|
$key .= $chars[random_int(0, $chLen - 1)]; |
434
|
|
|
} |
435
|
|
|
|
436
|
|
|
return base64_encode($key); |
437
|
|
|
} |
438
|
|
|
} |
439
|
|
|
|
This check looks for type mismatches where the missing type is
false
. This is usually indicative of an error condtion.Consider the follow example
This function either returns a new
DateTime
object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returnedfalse
before passing on the value to another function or method that may not be able to handle afalse
.