Completed
Push — master ( 5ff9f5...d9c485 )
by Mr
05:41
created

Client::openSocket()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 32

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 32
ccs 14
cts 14
cp 1
rs 9.408
c 0
b 0
f 0
cc 3
nc 4
nop 0
crap 3
1
<?php
2
3
namespace RouterOS;
4
5
use RouterOS\Exceptions\ClientException;
6
use RouterOS\Exceptions\ConfigException;
7
use RouterOS\Exceptions\QueryException;
8
use RouterOS\Helpers\ArrayHelper;
9
10
/**
11
 * Class Client for RouterOS management
12
 *
13
 * @package RouterOS
14
 * @since   0.1
15
 */
16
class Client implements Interfaces\ClientInterface
17
{
18
    /**
19
     * Socket resource
20
     *
21
     * @var resource|null
22
     */
23
    private $_socket;
24
25
    /**
26
     * Code of error
27
     *
28
     * @var int
29
     */
30
    private $_socket_err_num;
31
32
    /**
33
     * Description of socket error
34
     *
35
     * @var string
36
     */
37
    private $_socket_err_str;
38
39
    /**
40
     * Configuration of connection
41
     *
42
     * @var \RouterOS\Config
43
     */
44
    private $_config;
45
46
    /**
47
     * Client constructor.
48
     *
49
     * @param   array|\RouterOS\Config $config
50
     * @throws  \RouterOS\Exceptions\ClientException
51
     * @throws  \RouterOS\Exceptions\ConfigException
52
     * @throws  \RouterOS\Exceptions\QueryException
53
     */
54 7
    public function __construct($config)
55
    {
56
        // If array then need create object
57 7
        if (\is_array($config)) {
58 1
            $config = new Config($config);
59
        }
60
61
        // Check for important keys
62 7
        if (true !== $key = ArrayHelper::checkIfKeysNotExist(['host', 'user', 'pass'], $config->getParameters())) {
63
            throw new ConfigException("Parameter '$key' of Config is not set or empty");
64
        }
65
66
        // Save config if everything is okay
67 7
        $this->setConfig($config);
68
69
        // Throw error if cannot to connect
70 7
        if (false === $this->connect()) {
71 1
            throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port'));
72
        }
73 5
    }
74
75
    /**
76
     * Get some parameter from config
77
     *
78
     * @param   string $parameter Name of required parameter
79
     * @return  mixed
80
     * @throws  \RouterOS\Exceptions\ConfigException
81
     */
82 7
    private function config(string $parameter)
83
    {
84 7
        return $this->_config->get($parameter);
85
    }
86
87
    /**
88
     * Return socket resource if is exist
89
     *
90
     * @return  \RouterOS\Config
91
     * @since   0.6
92
     */
93
    public function getConfig(): Config
94
    {
95
        return $this->_config;
96
    }
97
98
    /**
99
     * Set configuration of client
100
     *
101
     * @param   \RouterOS\Config $config
102
     * @since   0.7
103
     */
104 7
    public function setConfig(Config $config)
105
    {
106 7
        $this->_config = $config;
107 7
    }
108
109
    /**
110
     * Encode given length in RouterOS format
111
     *
112
     * @param   string $string
113
     * @return  string Encoded length
114
     * @throws  \RouterOS\Exceptions\ClientException
115
     */
116 6
    private function encodeLength(string $string): string
117
    {
118 6
        $length = \strlen($string);
119
120 6
        if ($length < 128) {
121 6
            $orig_length = $length;
122 6
            $offset      = -1;
123
        } elseif ($length < 16384) {
124
            $orig_length = $length | 0x8000;
125
            $offset      = -2;
126
        } elseif ($length < 2097152) {
127
            $orig_length = $length | 0xC00000;
128
            $offset      = -3;
129
        } elseif ($length < 268435456) {
130
            $orig_length = $length | 0xE0000000;
131
            $offset      = -4;
132
        } else {
133
            throw new ClientException("Unable to encode length of '$string'");
134
        }
135
136
        // Pack string to binary format
137 6
        $result = pack('I*', $orig_length);
138
        // Parse binary string to array
139 6
        $result = str_split($result);
140
        // Reverse array
141 6
        $result = array_reverse($result);
142
        // Extract values from offset to end of array
143 6
        $result = \array_slice($result, $offset);
144
145
        // Sew items into one line
146 6
        $output = null;
147 6
        foreach ($result as $item) {
148 6
            $output .= $item;
149
        }
150
151 6
        return $output;
152
    }
153
154
    /**
155
     * Read length of line
156
     *
157
     * @param   int $byte
158
     * @return  int
159
     */
160 6
    private function getLength(int $byte): int
161
    {
162
        // If the first bit is set then we need to remove the first four bits, shift left 8
163
        // and then read another byte in.
164
        // We repeat this for the second and third bits.
165
        // If the fourth bit is set, we need to remove anything left in the first byte
166
        // and then read in yet another byte.
167 6
        if ($byte & 128) {
168
            if (($byte & 192) === 128) {
169
                $length = (($byte & 63) << 8) + \ord(fread($this->_socket, 1));
170
            } elseif (($byte & 224) === 192) {
171
                $length = (($byte & 31) << 8) + \ord(fread($this->_socket, 1));
172
                $length = ($length << 8) + \ord(fread($this->_socket, 1));
173
            } elseif (($byte & 240) === 224) {
174
                $length = (($byte & 15) << 8) + \ord(fread($this->_socket, 1));
175
                $length = ($length << 8) + \ord(fread($this->_socket, 1));
176
                $length = ($length << 8) + \ord(fread($this->_socket, 1));
177
            } else {
178
                $length = \ord(fread($this->_socket, 1));
179
                $length = ($length << 8) + \ord(fread($this->_socket, 1)) * 3;
180
                $length = ($length << 8) + \ord(fread($this->_socket, 1));
181
                $length = ($length << 8) + \ord(fread($this->_socket, 1));
182
            }
183
        } else {
184 6
            $length = $byte;
185
        }
186 6
        return $length;
187
    }
188
189
    /**
190
     * Send write query to RouterOS (with or without tag)
191
     *
192
     * @param   string|array|\RouterOS\Query $query
193
     * @return  \RouterOS\Client
194
     * @throws  \RouterOS\Exceptions\ClientException
195
     * @throws  \RouterOS\Exceptions\QueryException
196
     */
197 6
    public function write($query): Client
198
    {
199 6
        if (\is_string($query)) {
200
            $query = new Query($query);
201 6
        } elseif (\is_array($query)) {
202
            $endpoint = array_shift($query);
203
            $query    = new Query($endpoint, $query);
204
        }
205
206 6
        if (!$query instanceof Query) {
207
            throw new QueryException('Parameters cannot be processed');
208
        }
209
210
        // Send commands via loop to router
211 6
        foreach ($query->getQuery() as $command) {
212 6
            $command = trim($command);
213 6
            fwrite($this->_socket, $this->encodeLength($command) . $command);
214
        }
215
216
        // Write zero-terminator
217 6
        fwrite($this->_socket, \chr(0));
218
219 6
        return $this;
220
    }
221
222
    /**
223
     * Read answer from server after query was executed
224
     *
225
     * @param   bool $parse
226
     * @return  array
227
     */
228 6
    public function read(bool $parse = true): array
229
    {
230
        // By default response is empty
231 6
        $response = [];
232
233
        // Read answer from socket in loop
234 6
        while (true) {
235
            // Read the first byte of input which gives us some or all of the length
236
            // of the remaining reply.
237 6
            $byte   = fread($this->_socket, 1);
238 6
            $length = $this->getLength(\ord($byte));
239
240
            // Save only non empty strings
241 6
            if ($length > 0) {
242
                // Save output line to response array
243 6
                $response[] = stream_get_contents($this->_socket, $length);
244
            }
245
246
            // If we get a !done line in response, change state of $isDone variable
247 6
            $isDone = ('!done' === end($response));
248
249
            // Get status about latest operation
250 6
            $status = stream_get_meta_data($this->_socket);
251
252
            // If we do not have unread bytes from socket or <-same and if done, then exit from loop
253 6
            if ((!$status['unread_bytes']) || (!$status['unread_bytes'] && $isDone)) {
254 6
                break;
255
            }
256
        }
257
258
        // Parse results and return
259 6
        return $parse ? $this->parseResponse($response) : $response;
260
    }
261
262
    /**
263
     * Alias for ->write() method
264
     *
265
     * @param   string|array|\RouterOS\Query $query
266
     * @return  \RouterOS\Client
267
     * @throws  \RouterOS\Exceptions\ClientException
268
     * @throws  \RouterOS\Exceptions\QueryException
269
     */
270
    public function w($query): Client
271
    {
272
        return $this->write($query);
273
    }
274
275
    /**
276
     * Alias for ->read() method
277
     *
278
     * @param   bool $parse
279
     * @return  array
280
     * @since   0.7
281
     */
282
    public function r(bool $parse = true): array
283
    {
284
        return $this->read($parse);
285
    }
286
287
    /**
288
     * Alias for ->write()->read() combination of methods
289
     *
290
     * @param   string|array|\RouterOS\Query $query
291
     * @param   bool                         $parse
292
     * @return  array
293
     * @throws  \RouterOS\Exceptions\ClientException
294
     * @throws  \RouterOS\Exceptions\QueryException
295
     * @since   0.6
296
     */
297
    public function wr($query, bool $parse = true): array
298
    {
299
        return $this->write($query)->read($parse);
300
    }
301
302
    /**
303
     * Parse response from Router OS
304
     *
305
     * @param   array $response Response data
306
     * @return  array Array with parsed data
307
     */
308 2
    private function parseResponse(array $response): array
309
    {
310 2
        $result = [];
311 2
        $i      = -1;
312 2
        $lines  = \count($response);
313 2
        foreach ($response as $key => $value) {
314
            switch ($value) {
315 2
                case '!re':
316 1
                    $i++;
317 1
                    break;
318 2
                case '!fatal':
319
                    $result = $response;
320
                    break 2;
321 2
                case '!trap':
322 2
                case '!done':
323
                    // Check for =ret=, .tag and any other following messages
324 2
                    for ($j = $key + 1; $j <= $lines; $j++) {
325
                        // If we have lines after current one
326 2
                        if (isset($response[$j])) {
327 1
                            $this->pregResponse($response[$j], $matches);
328 1
                            if (!empty($matches)) {
329 1
                                $result['after'][$matches[1][0]] = $matches[2][0];
330
                            }
331
                        }
332
                    }
333 2
                    break 2;
334
                default:
335 1
                    $this->pregResponse($value, $matches);
336 1
                    if (!empty($matches)) {
337 1
                        $result[$i][$matches[1][0]] = $matches[2][0];
338
                    }
339 1
                    break;
340
            }
341
        }
342 2
        return $result;
343
    }
344
345
    /**
346
     * Parse result from RouterOS by regular expression
347
     *
348
     * @param   string $value
349
     * @param   array  $matches
350
     */
351 2
    private function pregResponse(string $value, &$matches)
352
    {
353 2
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
354 2
    }
355
356
    /**
357
     * Authorization logic
358
     *
359
     * @return  bool
360
     * @throws  \RouterOS\Exceptions\ClientException
361
     * @throws  \RouterOS\Exceptions\ConfigException
362
     * @throws  \RouterOS\Exceptions\QueryException
363
     */
364 6
    private function login(): bool
365
    {
366
        // If legacy login scheme is enabled
367 6
        if ($this->config('legacy')) {
368
            // For the first we need get hash with salt
369 1
            $query    = new Query('/login');
370 1
            $response = $this->write($query)->read();
371
372
            // Now need use this hash for authorization
373 1
            $query = (new Query('/login'))
374 1
                ->add('=name=' . $this->config('user'))
375 1
                ->add('=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret'])));
376
        } else {
377
            // Just login with our credentials
378 5
            $query = (new Query('/login'))
379 5
                ->add('=name=' . $this->config('user'))
380 5
                ->add('=password=' . $this->config('pass'));
381
        }
382
383
        // Execute query and get response
384 6
        $response = $this->write($query)->read(false);
385
386
        // Return true if we have only one line from server and this line is !done
387 6
        return isset($response[0]) && $response[0] === '!done';
388
    }
389
390
    /**
391
     * Connect to socket server
392
     *
393
     * @return  bool
394
     * @throws  \RouterOS\Exceptions\ClientException
395
     * @throws  \RouterOS\Exceptions\ConfigException
396
     * @throws  \RouterOS\Exceptions\QueryException
397
     */
398 7
    private function connect(): bool
399
    {
400
        // By default we not connected
401 7
        $connected = false;
402
403
        // Few attempts in loop
404 7
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
405
406
            // Initiate socket session
407 7
            $this->openSocket();
408
409
            // If socket is active
410 6
            if ($this->getSocket()) {
411
412
                // If we logged in then exit from loop
413 6
                if (true === $this->login()) {
414 5
                    $connected = true;
415 5
                    break;
416
                }
417
418
                // Else close socket and start from begin
419 1
                $this->closeSocket();
420
            }
421
422
            // Sleep some time between tries
423 1
            sleep($this->config('delay'));
424
        }
425
426
        // Return status of connection
427 6
        return $connected;
428
    }
429
430
    /**
431
     * Save socket resource to static variable
432
     *
433
     * @param   resource $socket
434
     */
435 6
    private function setSocket($socket)
436
    {
437 6
        $this->_socket = $socket;
438 6
    }
439
440
    /**
441
     * Return socket resource if is exist
442
     *
443
     * @return  resource
444
     */
445 6
    public function getSocket()
446
    {
447 6
        return $this->_socket;
448
    }
449
450
    /**
451
     * Initiate socket session
452
     *
453
     * @throws  \RouterOS\Exceptions\ClientException
454
     * @throws  \RouterOS\Exceptions\ConfigException
455
     */
456 7
    private function openSocket()
457
    {
458
        // Default: Context for ssl
459 7
        $context = stream_context_create([
460 7
            'ssl' => [
461
                'ciphers'          => 'ADH:ALL',
462
                'verify_peer'      => false,
463
                'verify_peer_name' => false
464
            ]
465
        ]);
466
467
        // Default: Proto tcp:// but for ssl we need ssl://
468 7
        $proto = $this->config('ssl') ? 'ssl://' : '';
469
470
        // Initiate socket client
471 7
        $socket = @stream_socket_client(
472 7
            $proto . $this->config('host') . ':' . $this->config('port'),
473 7
            $this->_socket_err_num,
474 7
            $this->_socket_err_str,
475 7
            $this->config('timeout'),
476 7
            STREAM_CLIENT_CONNECT,
477 7
            $context
478
        );
479
480
        // Throw error is socket is not initiated
481 7
        if (!$socket) {
482 1
            throw new ClientException('Unable to establish socket session, ' . $this->_socket_err_str);
483
        }
484
485
        // Save socket to static variable
486 6
        return $this->setSocket($socket);
487
    }
488
489
    /**
490
     * Close socket session
491
     *
492
     * @return bool
493
     */
494 1
    private function closeSocket(): bool
495
    {
496 1
        return fclose($this->_socket);
497
    }
498
}
499