Completed
Push — master ( e929d6...be85d1 )
by Mr
04:49
created

Client::readAsIterator()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 2
cts 2
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
crap 1
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
     *
40
     * @throws \RouterOS\Exceptions\ClientException
41
     * @throws \RouterOS\Exceptions\ConfigException
42
     * @throws \RouterOS\Exceptions\QueryException
43
     */
44 17
    public function __construct($config)
45
    {
46
        // If array then need create object
47 17
        if (\is_array($config)) {
48 14
            $config = new Config($config);
49
        }
50
51
        // Check for important keys
52 17
        if (true !== $key = ArrayHelper::checkIfKeysNotExist(['host', 'user', 'pass'], $config->getParameters())) {
53 1
            throw new ConfigException("One or few parameters '$key' of Config is not set or empty");
54
        }
55
56
        // Save config if everything is okay
57 16
        $this->_config = $config;
58
59
        // Throw error if cannot to connect
60 16
        if (false === $this->connect()) {
61
            throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port'));
62
        }
63 13
    }
64
65
    /**
66
     * Get some parameter from config
67
     *
68
     * @param string $parameter Name of required parameter
69
     *
70
     * @return mixed
71
     * @throws \RouterOS\Exceptions\ConfigException
72
     */
73 16
    private function config(string $parameter)
74
    {
75 16
        return $this->_config->get($parameter);
76
    }
77
78
    /**
79
     * Send write query to RouterOS
80
     *
81
     * @param string|array|\RouterOS\Query $query
82
     *
83
     * @return \RouterOS\Client
84
     * @throws \RouterOS\Exceptions\QueryException
85
     * @deprecated
86
     * @codeCoverageIgnore
87
     */
88
    public function write($query): Client
89
    {
90
        if (\is_string($query)) {
91
            $query = new Query($query);
92
        } elseif (\is_array($query)) {
93
            $endpoint = array_shift($query);
94
            $query    = new Query($endpoint, $query);
95
        }
96
97
        if (!$query instanceof Query) {
98
            throw new QueryException('Parameters cannot be processed');
99
        }
100
101
        // Submit query to RouterOS
102
        return $this->writeRAW($query);
103
    }
104
105
    /**
106
     * Send write query to RouterOS (modern version of write)
107
     *
108
     * @param string|Query $endpoint   Path of API query or Query object
109
     * @param array|null   $where      List of where filters
110
     * @param string|null  $operations Some operations which need make on response
111
     * @param string|null  $tag        Mark query with tag
112
     *
113
     * @return \RouterOS\Client
114
     * @throws \RouterOS\Exceptions\QueryException
115
     * @throws \RouterOS\Exceptions\ClientException
116
     * @since 1.0.0
117
     */
118 14
    public function query($endpoint, array $where = null, string $operations = null, string $tag = null): Client
119
    {
120
        // If endpoint is string then build Query object
121 14
        $query = ($endpoint instanceof Query)
122 14
            ? $endpoint
123 14
            : new Query($endpoint);
124
125
        // Parse where array
126 14
        if (!empty($where)) {
127
128
            // If array is multidimensional, then parse each line
129 3
            if (is_array($where[0])) {
130 2
                foreach ($where as $item) {
131
132
                    // Null by default
133 2
                    $key      = null;
134 2
                    $operator = null;
135 2
                    $value    = null;
136
137 2 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...
138 2
                        case 1:
139 1
                            list($key) = $item;
140 1
                            break;
141 2
                        case 2:
142 1
                            list($key, $operator) = $item;
143 1
                            break;
144 2
                        case 3:
145 1
                            list($key, $operator, $value) = $item;
146 1
                            break;
147
                        default:
148 1
                            throw new ClientException('From 1 to 3 parameters of "where" condition is allowed');
149
                    }
150 1
                    $query->where($key, $operator, $value);
151
                }
152
            } else {
153
                // Null by default
154 2
                $key      = null;
155 2
                $operator = null;
156 2
                $value    = null;
157
158 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...
159 2
                    case 1:
160 1
                        list($key) = $where;
161 1
                        break;
162 2
                    case 2:
163 1
                        list($key, $operator) = $where;
164 1
                        break;
165 2
                    case 3:
166 1
                        list($key, $operator, $value) = $where;
167 1
                        break;
168
                    default:
169 1
                        throw new ClientException('From 1 to 3 parameters of "where" condition is allowed');
170
                }
171
172 1
                $query->where($key, $operator, $value);
173
            }
174
175
        }
176
177
        // Append operations if set
178 14
        if (!empty($operations)) {
179 1
            $query->operations($operations);
180
        }
181
182
        // Append tag if set
183 14
        if (!empty($tag)) {
184 1
            $query->tag($tag);
185
        }
186
187
        // Submit query to RouterOS
188 14
        return $this->writeRAW($query);
189
    }
190
191
    /**
192
     * Send write query object to RouterOS
193
     *
194
     * @param \RouterOS\Query $query
195
     *
196
     * @return \RouterOS\Client
197
     * @throws \RouterOS\Exceptions\QueryException
198
     * @since 1.0.0
199
     */
200 14
    private function writeRAW(Query $query): Client
201
    {
202
        // Send commands via loop to router
203 14
        foreach ($query->getQuery() as $command) {
204 14
            $this->_connector->writeWord(trim($command));
205
        }
206
207
        // Write zero-terminator (empty string)
208 14
        $this->_connector->writeWord('');
209
210 14
        return $this;
211
    }
212
213
    /**
214
     * Read RAW response from RouterOS
215
     *
216
     * @return array
217
     * @since 1.0.0
218
     */
219 14
    private function readRAW(): array
220
    {
221
        // By default response is empty
222 14
        $response = [];
223
        // We have to wait a !done or !fatal
224 14
        $lastReply = false;
225
226
        // Read answer from socket in loop
227 14
        while (true) {
228 14
            $word = $this->_connector->readWord();
229
230 14
            if ('' === $word) {
231 14
                if ($lastReply) {
232
                    // We received a !done or !fatal message in a precedent loop
233
                    // response is complete
234 14
                    break;
235
                }
236
                // We did not receive the !done or !fatal message
237
                // This 0 length message is the end of a reply !re or !trap
238
                // We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message
239 6
                continue;
240
            }
241
242
            // Save output line to response array
243 14
            $response[] = $word;
244
245
            // If we get a !done or !fatal line in response, we are now ready to finish the read
246
            // but we need to wait a 0 length message, switch the flag
247 14
            if ('!done' === $word || '!fatal' === $word) {
248 14
                $lastReply = true;
249
            }
250
        }
251
252
        // Parse results and return
253 14
        return $response;
254
    }
255
256
    /**
257
     * Read answer from server after query was executed
258
     *
259
     * A Mikrotik reply is formed of blocks
260
     * Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal')
261
     * Each block end with an zero byte (empty line)
262
     * Reply ends with a complete !done or !fatal block (ended with 'empty line')
263
     * A !fatal block precedes TCP connexion close
264
     *
265
     * @param bool $parse
266
     *
267
     * @return mixed
268
     */
269 14
    public function read(bool $parse = true)
270
    {
271
        // Read RAW response
272 14
        $response = $this->readRAW();
273
274
        // Parse results and return
275 14
        return $parse ? $this->rosario($response) : $response;
276
    }
277
278
    /**
279
     * Read using Iterators to improve performance on large dataset
280
     *
281
     * @return \RouterOS\ResponseIterator
282
     * @since 1.0.0
283
     */
284 4
    public function readAsIterator(): ResponseIterator
285
    {
286 4
        return new ResponseIterator($this);
287
    }
288
289
    /**
290
     * This method was created by memory save reasons, it convert response
291
     * from RouterOS to readable array in safe way.
292
     *
293
     * @param array $raw Array RAW response from server
294
     *
295
     * @return mixed
296
     *
297
     * Based on RouterOSResponseArray solution by @arily
298
     *
299
     * @link    https://github.com/arily/RouterOSResponseArray
300
     * @since   1.0.0
301
     */
302 3
    private function rosario(array $raw): array
303
    {
304
        // This RAW should't be an error
305 3
        $positions = array_keys($raw, '!re');
306 3
        $count     = count($raw);
307 3
        $result    = [];
308
309 3
        if (isset($positions[1])) {
310
311 1
            foreach ($positions as $key => $position) {
312
                // Get length of future block
313 1
                $length = isset($positions[$key + 1])
314 1
                    ? $positions[$key + 1] - $position + 1
315 1
                    : $count - $position;
316
317
                // Convert array to simple items
318 1
                $item = [];
319 1
                for ($i = 1; $i < $length; $i++) {
320 1
                    $item[] = array_shift($raw);
321
                }
322
323
                // Save as result
324 1
                $result[] = $this->parseResponse($item)[0];
325
            }
326
327
        } else {
328 3
            $result = $this->parseResponse($raw);
329
        }
330
331 3
        return $result;
332
    }
333
334
    /**
335
     * Parse response from Router OS
336
     *
337
     * @param array $response Response data
338
     *
339
     * @return array Array with parsed data
340
     */
341 4
    public function parseResponse(array $response): array
342
    {
343 4
        $result = [];
344 4
        $i      = -1;
345 4
        $lines  = \count($response);
346 4
        foreach ($response as $key => $value) {
347 4
            switch ($value) {
348 4
                case '!re':
349 2
                    $i++;
350 2
                    break;
351 4
                case '!fatal':
352 1
                    $result = $response;
353 1
                    break 2;
354 3
                case '!trap':
355 3
                case '!done':
356
                    // Check for =ret=, .tag and any other following messages
357 3
                    for ($j = $key + 1; $j <= $lines; $j++) {
358
                        // If we have lines after current one
359 3
                        if (isset($response[$j])) {
360 2
                            $this->pregResponse($response[$j], $matches);
361 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...
362 2
                                $result['after'][$matches[1][0]] = $matches[2][0];
363
                            }
364
                        }
365
                    }
366 3
                    break 2;
367
                default:
368 2
                    $this->pregResponse($value, $matches);
369 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...
370 2
                        $result[$i][$matches[1][0]] = $matches[2][0];
371
                    }
372 2
                    break;
373
            }
374
        }
375 4
        return $result;
376
    }
377
378
    /**
379
     * Parse result from RouterOS by regular expression
380
     *
381
     * @param string $value
382
     * @param array  $matches
383
     */
384 3
    private function pregResponse(string $value, &$matches)
385
    {
386 3
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
387 3
    }
388
389
    /**
390
     * Authorization logic
391
     *
392
     * @param bool $legacyRetry Retry login if we detect legacy version of RouterOS
393
     *
394
     * @return bool
395
     * @throws \RouterOS\Exceptions\ClientException
396
     * @throws \RouterOS\Exceptions\ConfigException
397
     * @throws \RouterOS\Exceptions\QueryException
398
     */
399 14
    private function login(bool $legacyRetry = false): bool
400
    {
401
        // If legacy login scheme is enabled
402 14
        if ($this->config('legacy')) {
403
            // For the first we need get hash with salt
404 1
            $response = $this->query('/login')->read();
405
406
            // Now need use this hash for authorization
407 1
            $query = new Query('/login', [
408 1
                '=name=' . $this->config('user'),
409 1
                '=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret']))
410
            ]);
411
        } else {
412
            // Just login with our credentials
413 13
            $query = new Query('/login', [
414 13
                '=name=' . $this->config('user'),
415 13
                '=password=' . $this->config('pass')
416
            ]);
417
418
            // If we set modern auth scheme but router with legacy firmware then need to retry query,
419
            // but need to prevent endless loop
420 13
            $legacyRetry = true;
421
        }
422
423
        // Execute query and get response
424 14
        $response = $this->query($query)->read(false);
425
426
        // if:
427
        //  - we have more than one response
428
        //  - response is '!done'
429
        // => problem with legacy version, swap it and retry
430
        // Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete?
431 14
        if ($legacyRetry && $this->isLegacy($response)) {
432
            $this->_config->set('legacy', true);
433
            return $this->login();
434
        }
435
436
        // If RouterOS answered with invalid credentials then throw error
437 14
        if (!empty($response[0]) && $response[0] === '!trap') {
438 1
            throw new ClientException('Invalid user name or password');
439
        }
440
441
        // Return true if we have only one line from server and this line is !done
442 13
        return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done');
443
    }
444
445
    /**
446
     * Detect by login request if firmware is legacy
447
     *
448
     * @param array $response
449
     *
450
     * @return bool
451
     * @throws ConfigException
452
     */
453 13
    private function isLegacy(array &$response): bool
454
    {
455 13
        return \count($response) > 1 && $response[0] === '!done' && !$this->config('legacy');
456
    }
457
458
    /**
459
     * Connect to socket server
460
     *
461
     * @return bool
462
     * @throws \RouterOS\Exceptions\ClientException
463
     * @throws \RouterOS\Exceptions\ConfigException
464
     * @throws \RouterOS\Exceptions\QueryException
465
     */
466 16
    private function connect(): bool
467
    {
468
        // By default we not connected
469 16
        $connected = false;
470
471
        // Few attempts in loop
472 16
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
473
474
            // Initiate socket session
475 16
            $this->openSocket();
476
477
            // If socket is active
478 14
            if (null !== $this->getSocket()) {
479 14
                $this->_connector = new APIConnector(new Streams\ResourceStream($this->getSocket()));
480
                // If we logged in then exit from loop
481 14
                if (true === $this->login()) {
482 13
                    $connected = true;
483 13
                    break;
484
                }
485
486
                // Else close socket and start from begin
487
                $this->closeSocket();
488
            }
489
490
            // Sleep some time between tries
491
            sleep($this->config('delay'));
492
        }
493
494
        // Return status of connection
495 13
        return $connected;
496
    }
497
}
498