Completed
Push — master ( 943916...b6d888 )
by Mr
03:33
created

Client::__construct()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 20
ccs 9
cts 9
cp 1
rs 9.6
c 0
b 0
f 0
cc 4
nc 6
nop 1
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
    use SocketTrait, ShortsTrait;
19
20
    /**
21
     * Configuration of connection
22
     *
23
     * @var \RouterOS\Config
24
     */
25
    private $_config;
26
27
    /**
28
     * API communication object
29
     *
30
     * @var \RouterOS\APIConnector
31
     */
32
33
    private $_connector;
34
35
    /**
36
     * Client constructor.
37
     *
38
     * @param array|\RouterOS\Config $config
39
     * @throws \RouterOS\Exceptions\ClientException
40
     * @throws \RouterOS\Exceptions\ConfigException
41
     * @throws \RouterOS\Exceptions\QueryException
42
     */
43 17
    public function __construct($config)
44
    {
45
        // If array then need create object
46 17
        if (\is_array($config)) {
47 14
            $config = new Config($config);
48
        }
49
50
        // Check for important keys
51 17
        if (true !== $key = ArrayHelper::checkIfKeysNotExist(['host', 'user', 'pass'], $config->getParameters())) {
52 1
            throw new ConfigException("One or few parameters '$key' of Config is not set or empty");
53
        }
54
55
        // Save config if everything is okay
56 16
        $this->_config = $config;
57
58
        // Throw error if cannot to connect
59 16
        if (false === $this->connect()) {
60 1
            throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port'));
61
        }
62 14
    }
63
64
    /**
65
     * Get some parameter from config
66
     *
67
     * @param string $parameter Name of required parameter
68
     * @return mixed
69
     * @throws \RouterOS\Exceptions\ConfigException
70
     */
71 16
    private function config(string $parameter)
72
    {
73 16
        return $this->_config->get($parameter);
74
    }
75
76
    /**
77
     * Send write query to RouterOS
78
     *
79
     * @param string|array|\RouterOS\Query $query
80
     * @return \RouterOS\Client
81
     * @throws \RouterOS\Exceptions\QueryException
82
     * @deprecated
83
     * @codeCoverageIgnore
84
     */
85
    public function write($query): Client
86
    {
87
        if (\is_string($query)) {
88
            $query = new Query($query);
89
        } elseif (\is_array($query)) {
90
            $endpoint = array_shift($query);
91
            $query    = new Query($endpoint, $query);
92
        }
93
94
        if (!$query instanceof Query) {
95
            throw new QueryException('Parameters cannot be processed');
96
        }
97
98
        // Submit query to RouterOS
99
        return $this->writeRAW($query);
100
    }
101
102
    /**
103
     * Send write query to RouterOS (modern version of write)
104
     *
105
     * @param string      $endpoint   Path of API query
106
     * @param array|null  $where      List of where filters
107
     * @param string|null $operations Some operations which need make on response
108
     * @param string|null $tag        Mark query with tag
109
     * @return \RouterOS\Client
110
     * @throws \RouterOS\Exceptions\QueryException
111
     * @throws \RouterOS\Exceptions\ClientException
112
     * @since 1.0.0
113
     */
114 4
    public function query(string $endpoint, array $where = null, string $operations = null, string $tag = null): Client
115
    {
116
        // If endpoint is string then build Query object
117 4
        $query = new Query($endpoint);
118
119
        // Parse where array
120 4
        if (!empty($where)) {
121
122
            // If array is multidimensional, then parse each line
123 3
            if (is_array($where[0])) {
124 1
                foreach ($where as $item) {
125
126
                    // Null by default
127 1
                    $key      = null;
128 1
                    $operator = null;
129 1
                    $value    = null;
130
131 1 View Code Duplication
                    switch (\count($item)) {
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...
132 1
                        case 1:
133
                            list($key) = $item;
134
                            break;
135 1
                        case 2:
136
                            list($key, $operator) = $item;
137
                            break;
138 1
                        case 3:
139
                            list($key, $operator, $value) = $item;
140
                            break;
141
                        default:
142 1
                            throw new ClientException('From 1 to 3 parameters of "where" condition is allowed');
143
                    }
144
                    $query->where($key, $operator, $value);
145
                }
146
            } else {
147
                // Null by default
148 2
                $key      = null;
149 2
                $operator = null;
150 2
                $value    = null;
151
152 2 View Code Duplication
                switch (\count($where)) {
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...
153 2
                    case 1:
154 1
                        list($key) = $where;
155 1
                        break;
156 2
                    case 2:
157 1
                        list($key, $operator) = $where;
158 1
                        break;
159 1
                    case 3:
160
                        list($key, $operator, $value) = $where;
161
                        break;
162
                    default:
163 1
                        throw new ClientException('From 1 to 3 parameters of "where" condition is allowed');
164
                }
165
166 1
                $query->where($key, $operator, $value);
167
            }
168
169
        }
170
171
        // Append operations if set
172 2
        if (!empty($operations)) {
173
            $query->operations($operations);
174
        }
175
176
        // Append tag if set
177 2
        if (!empty($tag)) {
178
            $query->tag($tag);
179
        }
180
181
        // Submit query to RouterOS
182 2
        return $this->writeRAW($query);
183
    }
184
185
    /**
186
     * Send write query object to RouterOS
187
     *
188
     * @param \RouterOS\Query $query
189
     * @return \RouterOS\Client
190
     * @throws \RouterOS\Exceptions\QueryException
191
     * @since 1.0.0
192
     */
193 15
    private function writeRAW(Query $query): Client
194
    {
195
        // Send commands via loop to router
196 15
        foreach ($query->getQuery() as $command) {
197 15
            $this->_connector->writeWord(trim($command));
198
        }
199
200
        // Write zero-terminator (empty string)
201 15
        $this->_connector->writeWord('');
202
203 15
        return $this;
204
    }
205
206
    /**
207
     * Read RAW response from RouterOS
208
     *
209
     * @return array
210
     * @since 1.0.0
211
     */
212 15
    private function readRAW(): array
213
    {
214
        // By default response is empty
215 15
        $response = [];
216
        // We have to wait a !done or !fatal
217 15
        $lastReply = false;
218
219
        // Read answer from socket in loop
220 15
        while (true) {
221 15
            $word = $this->_connector->readWord();
222
223 15
            if ('' === $word) {
224 15
                if ($lastReply) {
225
                    // We received a !done or !fatal message in a precedent loop
226
                    // response is complete
227 15
                    break;
228
                }
229
                // We did not receive the !done or !fatal message
230
                // This 0 length message is the end of a reply !re or !trap
231
                // We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message
232 6
                continue;
233
            }
234
235
            // Save output line to response array
236 15
            $response[] = $word;
237
238
            // If we get a !done or !fatal line in response, we are now ready to finish the read
239
            // but we need to wait a 0 length message, switch the flag
240 15
            if ('!done' === $word || '!fatal' === $word) {
241 15
                $lastReply = true;
242
            }
243
        }
244
245
        // Parse results and return
246 15
        return $response;
247
    }
248
249
    /**
250
     * Read answer from server after query was executed
251
     *
252
     * A Mikrotik reply is formed of blocks
253
     * Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal')
254
     * Each block end with an zero byte (empty line)
255
     * Reply ends with a complete !done or !fatal block (ended with 'empty line')
256
     * A !fatal block precedes TCP connexion close
257
     *
258
     * @param bool $parse
259
     * @return mixed
260
     */
261 15
    public function read(bool $parse = true)
262
    {
263
        // Read RAW response
264 15
        $response = $this->readRAW();
265
266
        // Parse results and return
267 15
        return $parse ? $this->rosario($response) : $response;
268
    }
269
270
    /**
271
     * Read using Iterators to improve performance on large dataset
272
     *
273
     * @return \RouterOS\ResponseIterator
274
     * @since 1.0.0
275
     */
276 4
    public function readAsIterator(): ResponseIterator
277
    {
278 4
        return new ResponseIterator($this);
279
    }
280
281
    /**
282
     * This method was created by memory save reasons, it convert response
283
     * from RouterOS to readable array in safe way.
284
     *
285
     * @param array $raw Array RAW response from server
286
     * @return mixed
287
     *
288
     * Based on RouterOSResponseArray solution by @arily
289
     *
290
     * @link    https://github.com/arily/RouterOSResponseArray
291
     * @since   1.0.0
292
     */
293 4
    private function rosario(array $raw): array
294
    {
295
        // This RAW should't be an error
296 4
        $positions = array_keys($raw, '!re');
297 4
        $count     = count($raw);
298 4
        $result    = [];
299
300 4
        if (isset($positions[1])) {
301
302 1
            foreach ($positions as $key => $position) {
303
                // Get length of future block
304 1
                $length = isset($positions[$key + 1])
305 1
                    ? $positions[$key + 1] - $position + 1
306 1
                    : $count - $position;
307
308
                // Convert array to simple items
309 1
                $item = [];
310 1
                for ($i = 1; $i < $length; $i++) {
311 1
                    $item[] = array_shift($raw);
312
                }
313
314
                // Save as result
315 1
                $result[] = $this->parseResponse($item)[0];
316
            }
317
318
        } else {
319 4
            $result = $this->parseResponse($raw);
320
        }
321
322 4
        return $result;
323
    }
324
325
    /**
326
     * Parse response from Router OS
327
     *
328
     * @param array $response Response data
329
     * @return array Array with parsed data
330
     */
331 5
    public function parseResponse(array $response): array
332
    {
333 5
        $result = [];
334 5
        $i      = -1;
335 5
        $lines  = \count($response);
336 5
        foreach ($response as $key => $value) {
337
            switch ($value) {
338 5
                case '!re':
339 2
                    $i++;
340 2
                    break;
341 5
                case '!fatal':
342 1
                    $result = $response;
343 1
                    break 2;
344 4
                case '!trap':
345 4
                case '!done':
346
                    // Check for =ret=, .tag and any other following messages
347 4
                    for ($j = $key + 1; $j <= $lines; $j++) {
348
                        // If we have lines after current one
349 4
                        if (isset($response[$j])) {
350 2
                            $this->pregResponse($response[$j], $matches);
351 2 View Code Duplication
                            if (isset($matches[1][0], $matches[2][0])) {
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...
352 2
                                $result['after'][$matches[1][0]] = $matches[2][0];
353
                            }
354
                        }
355
                    }
356 4
                    break 2;
357
                default:
358 2
                    $this->pregResponse($value, $matches);
359 2 View Code Duplication
                    if (isset($matches[1][0], $matches[2][0])) {
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...
360 2
                        $result[$i][$matches[1][0]] = $matches[2][0];
361
                    }
362 2
                    break;
363
            }
364
        }
365 5
        return $result;
366
    }
367
368
    /**
369
     * Parse result from RouterOS by regular expression
370
     *
371
     * @param string $value
372
     * @param array  $matches
373
     */
374 4
    private function pregResponse(string $value, &$matches)
375
    {
376 4
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
377 4
    }
378
379
    /**
380
     * Authorization logic
381
     *
382
     * @param bool $legacyRetry Retry login if we detect legacy version of RouterOS
383
     * @return bool
384
     * @throws \RouterOS\Exceptions\ClientException
385
     * @throws \RouterOS\Exceptions\ConfigException
386
     * @throws \RouterOS\Exceptions\QueryException
387
     */
388 15
    private function login(bool $legacyRetry = false): bool
389
    {
390
        // If legacy login scheme is enabled
391 15
        if ($this->config('legacy')) {
392
            // For the first we need get hash with salt
393 2
            $response = $this->write('/login')->read();
0 ignored issues
show
Deprecated Code introduced by
The method RouterOS\Client::write() has been deprecated.

This method has been deprecated.

Loading history...
394
395
            // Now need use this hash for authorization
396 2
            $query = new Query('/login', [
397 2
                '=name=' . $this->config('user'),
398 2
                '=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret']))
399
            ]);
400
        } else {
401
            // Just login with our credentials
402 14
            $query = new Query('/login', [
403 14
                '=name=' . $this->config('user'),
404 14
                '=password=' . $this->config('pass')
405
            ]);
406
407
            // If we set modern auth scheme but router with legacy firmware then need to retry query,
408
            // but need to prevent endless loop
409 14
            $legacyRetry = true;
410
        }
411
412
        // Execute query and get response
413 15
        $response = $this->write($query)->read(false);
0 ignored issues
show
Deprecated Code introduced by
The method RouterOS\Client::write() has been deprecated.

This method has been deprecated.

Loading history...
414
415
        // if:
416
        //  - we have more than one response
417
        //  - response is '!done'
418
        // => problem with legacy version, swap it and retry
419
        // Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete?
420 15
        if ($legacyRetry && $this->isLegacy($response)) {
421 1
            $this->_config->set('legacy', true);
422 1
            return $this->login();
423
        }
424
425
        // Return true if we have only one line from server and this line is !done
426 15
        return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done');
427
    }
428
429
    /**
430
     * Detect by login request if firmware is legacy
431
     *
432
     * @param array $response
433
     * @return bool
434
     * @throws ConfigException
435
     */
436 14
    private function isLegacy(array &$response): bool
437
    {
438 14
        return \count($response) > 1 && $response[0] === '!done' && !$this->config('legacy');
439
    }
440
441
    /**
442
     * Connect to socket server
443
     *
444
     * @return bool
445
     * @throws \RouterOS\Exceptions\ClientException
446
     * @throws \RouterOS\Exceptions\ConfigException
447
     * @throws \RouterOS\Exceptions\QueryException
448
     */
449 16
    private function connect(): bool
450
    {
451
        // By default we not connected
452 16
        $connected = false;
453
454
        // Few attempts in loop
455 16
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
456
457
            // Initiate socket session
458 16
            $this->openSocket();
459
460
            // If socket is active
461 15
            if (null !== $this->getSocket()) {
462 15
                $this->_connector = new APIConnector(new Streams\ResourceStream($this->getSocket()));
463
                // If we logged in then exit from loop
464 15
                if (true === $this->login()) {
465 14
                    $connected = true;
466 14
                    break;
467
                }
468
469
                // Else close socket and start from begin
470 1
                $this->closeSocket();
471
            }
472
473
            // Sleep some time between tries
474 1
            sleep($this->config('delay'));
475
        }
476
477
        // Return status of connection
478 15
        return $connected;
479
    }
480
}
481