Completed
Push — master ( c5989b...cbac8b )
by Mr
14s queued 11s
created

Client::preQuery()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 4

Importance

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