Completed
Push — master ( 9497fc...721676 )
by Mr
04:00
created

Client::connect()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 31

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 31
ccs 11
cts 11
cp 1
rs 9.424
c 0
b 0
f 0
cc 4
nc 4
nop 0
crap 4
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
            } else {
245
                // Read next line
246 6
                stream_get_contents($this->_socket, $length);
247
            }
248
249
            // If we get a !done line in response, change state of $isDone variable
250 6
            $isDone = ('!done' === end($response));
251
252
            // Get status about latest operation
253 6
            $status = stream_get_meta_data($this->_socket);
254
255
            // If we do not have unread bytes from socket or <-same and if done, then exit from loop
256 6
            if ((!$status['unread_bytes']) || (!$status['unread_bytes'] && $isDone)) {
257 6
                break;
258
            }
259
        }
260
261
        // Parse results and return
262 6
        return $parse ? $this->parseResponse($response) : $response;
263
    }
264
265
    /**
266
     * Alias for ->write() method
267
     *
268
     * @param   string|array|\RouterOS\Query $query
269
     * @return  \RouterOS\Client
270
     * @throws  \RouterOS\Exceptions\ClientException
271
     * @throws  \RouterOS\Exceptions\QueryException
272
     */
273
    public function w($query): Client
274
    {
275
        return $this->write($query);
276
    }
277
278
    /**
279
     * Alias for ->read() method
280
     *
281
     * @param   bool $parse
282
     * @return  array
283
     * @since   0.7
284
     */
285
    public function r(bool $parse = true): array
286
    {
287
        return $this->read($parse);
288
    }
289
290
    /**
291
     * Alias for ->write()->read() combination of methods
292
     *
293
     * @param   string|array|\RouterOS\Query $query
294
     * @param   bool                         $parse
295
     * @return  array
296
     * @throws  \RouterOS\Exceptions\ClientException
297
     * @throws  \RouterOS\Exceptions\QueryException
298
     * @since   0.6
299
     */
300
    public function wr($query, bool $parse = true): array
301
    {
302
        return $this->write($query)->read($parse);
303
    }
304
305
    /**
306
     * Parse response from Router OS
307
     *
308
     * @param   array $response Response data
309
     * @return  array Array with parsed data
310
     */
311 2
    private function parseResponse(array $response): array
312
    {
313 2
        $result = [];
314 2
        $i      = -1;
315 2
        $lines  = \count($response);
316 2
        foreach ($response as $key => $value) {
317
            switch ($value) {
318 2
                case '!re':
319 1
                    $i++;
320 1
                    break;
321 2
                case '!fatal':
322
                    $result = $response;
323
                    break 2;
324 2
                case '!trap':
325 2
                case '!done':
326
                    // Check for =ret=, .tag and any other following messages
327 2
                    for ($j = $key + 1; $j <= $lines; $j++) {
328
                        // If we have lines after current one
329 2
                        if (isset($response[$j])) {
330 1
                            $this->pregResponse($response[$j], $matches);
331 1
                            if (!empty($matches)) {
332 1
                                $result['after'][$matches[1][0]] = $matches[2][0];
333
                            }
334
                        }
335
                    }
336 2
                    break 2;
337
                default:
338 1
                    $this->pregResponse($value, $matches);
339 1
                    if (!empty($matches)) {
340 1
                        $result[$i][$matches[1][0]] = $matches[2][0];
341
                    }
342 1
                    break;
343
            }
344
        }
345 2
        return $result;
346
    }
347
348
    /**
349
     * Parse result from RouterOS by regular expression
350
     *
351
     * @param   string $value
352
     * @param   array  $matches
353
     */
354 2
    private function pregResponse(string $value, &$matches)
355
    {
356 2
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
357 2
    }
358
359
    /**
360
     * Authorization logic
361
     *
362
     * @return  bool
363
     * @throws  \RouterOS\Exceptions\ClientException
364
     * @throws  \RouterOS\Exceptions\ConfigException
365
     * @throws  \RouterOS\Exceptions\QueryException
366
     */
367 6
    private function login(): bool
368
    {
369
        // If legacy login scheme is enabled
370 6
        if ($this->config('legacy')) {
371
            // For the first we need get hash with salt
372 1
            $query    = new Query('/login');
373 1
            $response = $this->write($query)->read();
374
375
            // Now need use this hash for authorization
376 1
            $query = (new Query('/login'))
377 1
                ->add('=name=' . $this->config('user'))
378 1
                ->add('=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret'])));
379
        } else {
380
            // Just login with our credentials
381 5
            $query = (new Query('/login'))
382 5
                ->add('=name=' . $this->config('user'))
383 5
                ->add('=password=' . $this->config('pass'));
384
        }
385
386
        // Execute query and get response
387 6
        $response = $this->write($query)->read(false);
388
389
        // Return true if we have only one line from server and this line is !done
390 6
        return isset($response[0]) && $response[0] === '!done';
391
    }
392
393
    /**
394
     * Connect to socket server
395
     *
396
     * @return  bool
397
     * @throws  \RouterOS\Exceptions\ClientException
398
     * @throws  \RouterOS\Exceptions\ConfigException
399
     * @throws  \RouterOS\Exceptions\QueryException
400
     */
401 7
    private function connect(): bool
402
    {
403
        // By default we not connected
404 7
        $connected = false;
405
406
        // Few attempts in loop
407 7
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
408
409
            // Initiate socket session
410 7
            $this->openSocket();
411
412
            // If socket is active
413 6
            if ($this->getSocket()) {
414
415
                // If we logged in then exit from loop
416 6
                if (true === $this->login()) {
417 5
                    $connected = true;
418 5
                    break;
419
                }
420
421
                // Else close socket and start from begin
422 1
                $this->closeSocket();
423
            }
424
425
            // Sleep some time between tries
426 1
            sleep($this->config('delay'));
427
        }
428
429
        // Return status of connection
430 6
        return $connected;
431
    }
432
433
    /**
434
     * Save socket resource to static variable
435
     *
436
     * @param   resource $socket
437
     */
438 6
    private function setSocket($socket)
439
    {
440 6
        $this->_socket = $socket;
441 6
    }
442
443
    /**
444
     * Return socket resource if is exist
445
     *
446
     * @return  resource
447
     */
448 6
    public function getSocket()
449
    {
450 6
        return $this->_socket;
451
    }
452
453
    /**
454
     * Initiate socket session
455
     *
456
     * @throws  \RouterOS\Exceptions\ClientException
457
     * @throws  \RouterOS\Exceptions\ConfigException
458
     */
459 7
    private function openSocket()
460
    {
461
        // Default: Context for ssl
462 7
        $context = stream_context_create([
463 7
            'ssl' => [
464
                'ciphers'          => 'ADH:ALL',
465
                'verify_peer'      => false,
466
                'verify_peer_name' => false
467
            ]
468
        ]);
469
470
        // Default: Proto tcp:// but for ssl we need ssl://
471 7
        $proto = $this->config('ssl') ? 'ssl://' : '';
472
473
        // Initiate socket client
474 7
        $socket = @stream_socket_client(
475 7
            $proto . $this->config('host') . ':' . $this->config('port'),
476 7
            $this->_socket_err_num,
477 7
            $this->_socket_err_str,
478 7
            $this->config('timeout'),
479 7
            STREAM_CLIENT_CONNECT,
480 7
            $context
481
        );
482
483
        // Throw error is socket is not initiated
484 7
        if (!$socket) {
485 1
            throw new ClientException('Unable to establish socket session, ' . $this->_socket_err_str);
486
        }
487
488
        // Save socket to static variable
489 6
        return $this->setSocket($socket);
490
    }
491
492
    /**
493
     * Close socket session
494
     *
495
     * @return bool
496
     */
497 1
    private function closeSocket(): bool
498
    {
499 1
        return fclose($this->_socket);
500
    }
501
}
502