Completed
Pull Request — master (#50)
by Mr
14:25 queued 05:48
created

Client::preParseResponse()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 7
ccs 5
cts 5
cp 1
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 4
crap 2
1
<?php
2
3
namespace RouterOS;
4
5
use DivineOmega\SSHConnection\SSHConnection;
6
use RouterOS\Exceptions\ClientException;
7
use RouterOS\Exceptions\ConnectException;
8
use RouterOS\Exceptions\BadCredentialsException;
9
use RouterOS\Exceptions\ConfigException;
10
use RouterOS\Interfaces\ClientInterface;
11
use RouterOS\Interfaces\QueryInterface;
12
use RouterOS\Helpers\ArrayHelper;
13
use function array_keys;
14
use function array_shift;
15
use function chr;
16
use function count;
17
use function is_array;
18
use function md5;
19
use function pack;
20
use function preg_match_all;
21
use function sleep;
22
use function trim;
23
24
/**
25
 * Class Client for RouterOS management
26
 *
27
 * @package RouterOS
28
 * @since   0.1
29
 */
30
class Client implements Interfaces\ClientInterface
31
{
32
    use SocketTrait, ShortsTrait;
33
34
    /**
35
     * Configuration of connection
36
     *
37
     * @var \RouterOS\Config
38
     */
39
    private $config;
40
41
    /**
42
     * API communication object
43
     *
44
     * @var \RouterOS\APIConnector
45
     */
46
    private $connector;
47
48
    /**
49
     * Some strings with custom output
50
     *
51
     * @var string
52
     */
53
    private $customOutput;
54
55
    /**
56
     * Client constructor.
57
     *
58
     * @param array|\RouterOS\Interfaces\ConfigInterface $config      Array with configuration or Config object
59
     * @param bool                                       $autoConnect If false it will skip auto-connect stage if not need to instantiate connection
60
     *
61
     * @throws \RouterOS\Exceptions\ClientException
62
     * @throws \RouterOS\Exceptions\ConnectException
63
     * @throws \RouterOS\Exceptions\BadCredentialsException
64
     * @throws \RouterOS\Exceptions\ConfigException
65
     * @throws \RouterOS\Exceptions\QueryException
66
     */
67 27
    public function __construct($config, bool $autoConnect = true)
68
    {
69
        // If array then need create object
70 27
        if (is_array($config)) {
71 26
            $config = new Config($config);
72
        }
73
74
        // Check for important keys
75 27
        if (true !== $key = ArrayHelper::checkIfKeysNotExist(['host', 'user', 'pass'], $config->getParameters())) {
76 1
            throw new ConfigException("One or few parameters '$key' of Config is not set or empty");
77
        }
78
79
        // Save config if everything is okay
80 27
        $this->config = $config;
81
82
        // Skip next step if not need to instantiate connection
83 27
        if (false === $autoConnect) {
84 1
            return;
85
        }
86
87
        // Throw error if cannot to connect
88 26
        if (false === $this->connect()) {
89 1
            throw new ConnectException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port'));
90
        }
91 26
    }
92
93
    /**
94
     * Get some parameter from config
95
     *
96
     * @param string $parameter Name of required parameter
97
     *
98
     * @return mixed
99
     * @throws \RouterOS\Exceptions\ConfigException
100
     */
101 26
    private function config(string $parameter)
102
    {
103 26
        return $this->config->get($parameter);
104
    }
105
106
    /**
107
     * Send write query to RouterOS (modern version of write)
108
     *
109
     * @param array|string|\RouterOS\Interfaces\QueryInterface $endpoint   Path of API query or Query object
110
     * @param array|null                                       $where      List of where filters
111
     * @param string|null                                      $operations Some operations which need make on response
112
     * @param string|null                                      $tag        Mark query with tag
113
     *
114
     * @return \RouterOS\Interfaces\ClientInterface
115
     * @throws \RouterOS\Exceptions\QueryException
116
     * @throws \RouterOS\Exceptions\ClientException
117
     * @throws \RouterOS\Exceptions\ConfigException
118
     * @since 1.0.0
119
     */
120 26
    public function query($endpoint, array $where = null, string $operations = null, string $tag = null): ClientInterface
121
    {
122
        // If endpoint is string then build Query object
123 26
        $query = ($endpoint instanceof Query)
124 26
            ? $endpoint
125 26
            : new Query($endpoint);
126
127
        // Parse where array
128 26
        if (!empty($where)) {
129
130
            // If array is multidimensional, then parse each line
131 6
            if (is_array($where[0])) {
132 5
                foreach ($where as $item) {
133 5
                    $query = $this->preQuery($item, $query);
134
                }
135
            } else {
136 2
                $query = $this->preQuery($where, $query);
137
            }
138
139
        }
140
141
        // Append operations if set
142 26
        if (!empty($operations)) {
143 1
            $query->operations($operations);
144
        }
145
146
        // Append tag if set
147 26
        if (!empty($tag)) {
148 1
            $query->tag($tag);
149
        }
150
151
        // Submit query to RouterOS
152 26
        return $this->writeRAW($query);
153
    }
154
155
    /**
156
     * Query helper
157
     *
158
     * @param array                               $item
159
     * @param \RouterOS\Interfaces\QueryInterface $query
160
     *
161
     * @return \RouterOS\Query
162
     * @throws \RouterOS\Exceptions\QueryException
163
     * @throws \RouterOS\Exceptions\ClientException
164
     */
165 6
    private function preQuery(array $item, QueryInterface $query): QueryInterface
166
    {
167
        // Null by default
168 6
        $key      = null;
169 6
        $operator = null;
170 6
        $value    = null;
171
172 6
        switch (count($item)) {
173 6
            case 1:
174 1
                [$key] = $item;
175 1
                break;
176 6
            case 2:
177 1
                [$key, $operator] = $item;
178 1
                break;
179 6
            case 3:
180 1
                [$key, $operator, $value] = $item;
181 1
                break;
182
            default:
183 5
                throw new ClientException('From 1 to 3 parameters of "where" condition is allowed');
184
        }
185
186 1
        return $query->where($key, $operator, $value);
187
    }
188
189
    /**
190
     * Send write query object to RouterOS
191
     *
192
     * @param \RouterOS\Interfaces\QueryInterface $query
193
     *
194
     * @return \RouterOS\Interfaces\ClientInterface
195
     * @throws \RouterOS\Exceptions\QueryException
196
     * @throws \RouterOS\Exceptions\ConfigException
197
     * @since 1.0.0
198
     */
199 26
    private function writeRAW(QueryInterface $query): ClientInterface
200
    {
201 26
        $commands = $query->getQuery();
202
203
        // Check if first command is export
204 26
        if (0 === strpos($commands[0], '/export')) {
205
206
            // Convert export command with all arguments to valid SSH command
207
            $arguments = explode('/', $commands[0]);
208
            unset($arguments[1]);
209
            $arguments = implode(' ', $arguments);
210
211
            // Call the router via ssh and store output of export
212
            $this->customOutput = $this->export($arguments);
213
214
            // Return current object
215
            return $this;
216
        }
217
218
        // Send commands via loop to router
219 26
        foreach ($commands as $command) {
220 26
            $this->connector->writeWord(trim($command));
221
        }
222
223
        // Write zero-terminator (empty string)
224 26
        $this->connector->writeWord('');
225
226
        // Return current object
227 26
        return $this;
228
    }
229
230
    /**
231
     * Read RAW response from RouterOS, it can be /export command results also, not only array from API
232
     *
233
     * @param array $options Additional options
234
     *
235
     * @return array|string
236
     * @since 1.0.0
237
     */
238 26
    public function readRAW(array $options = [])
239
    {
240
        // By default response is empty
241 26
        $response = [];
242
        // We have to wait a !done or !fatal
243 26
        $lastReply = false;
244
        // Count !re in response
245 26
        $countResponse = 0;
246
247
        // Convert strings to array and return results
248 26
        if ($this->isCustomOutput()) {
249
            // Return RAW configuration
250
            return $this->customOutput;
251
        }
252
253
        // Read answer from socket in loop
254 26
        while (true) {
255 26
            $word = $this->connector->readWord();
256
257
            //Limit response number to finish the read
258 26
            if (isset($options['count']) && $countResponse >= (int) $options['count']) {
259 1
                $lastReply = true;
260
            }
261
262 26
            if ('' === $word) {
263 26
                if ($lastReply) {
264
                    // We received a !done or !fatal message in a precedent loop
265
                    // response is complete
266 26
                    break;
267
                }
268
                // We did not receive the !done or !fatal message
269
                // This 0 length message is the end of a reply !re or !trap
270
                // We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message
271 5
                continue;
272
            }
273
274
            // Save output line to response array
275 26
            $response[] = $word;
276
277
            // If we get a !done or !fatal line in response, we are now ready to finish the read
278
            // but we need to wait a 0 length message, switch the flag
279 26
            if ('!done' === $word || '!fatal' === $word) {
280 26
                $lastReply = true;
281
            }
282
283
            // If we get a !re line in response, we increment the variable
284 26
            if ('!re' === $word) {
285 3
                $countResponse++;
286
            }
287
        }
288
289
        // Parse results and return
290 26
        return $response;
291
    }
292
293
    /**
294
     * Read answer from server after query was executed
295
     *
296
     * A Mikrotik reply is formed of blocks
297
     * Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal')
298
     * Each block end with an zero byte (empty line)
299
     * Reply ends with a complete !done or !fatal block (ended with 'empty line')
300
     * A !fatal block precedes TCP connexion close
301
     *
302
     * @param bool  $parse   If need parse output to array
303
     * @param array $options Additional options
304
     *
305
     * @return mixed
306
     */
307 26
    public function read(bool $parse = true, array $options = [])
308
    {
309
        // Read RAW response
310 26
        $response = $this->readRAW($options);
311
312
        // Return RAW configuration if custom output is set
313 26
        if ($this->isCustomOutput()) {
314
            $this->customOutput = null;
315
            return $response;
316
        }
317
318
        // Parse results and return
319 26
        return $parse ? $this->rosario($response) : $response;
0 ignored issues
show
Bug introduced by
It seems like $response defined by $this->readRAW($options) on line 310 can also be of type string; however, RouterOS\Client::rosario() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
320
    }
321
322
    /**
323
     * Read using Iterators to improve performance on large dataset
324
     *
325
     * @param array $options Additional options
326
     *
327
     * @return \RouterOS\ResponseIterator
328
     * @since 1.0.0
329
     */
330 3
    public function readAsIterator(array $options = []): ResponseIterator
331
    {
332 3
        return new ResponseIterator($this, $options);
333
    }
334
335
    /**
336
     * This method was created by memory save reasons, it convert response
337
     * from RouterOS to readable array in safe way.
338
     *
339
     * @param array $raw Array RAW response from server
340
     *
341
     * @return mixed
342
     *
343
     * Based on RouterOSResponseArray solution by @arily
344
     *
345
     * @see   https://github.com/arily/RouterOSResponseArray
346
     * @since 1.0.0
347
     */
348 4
    private function rosario(array $raw): array
349
    {
350
        // This RAW should't be an error
351 4
        $positions = array_keys($raw, '!re');
352 4
        $count     = count($raw);
353 4
        $result    = [];
354
355 4
        if (isset($positions[1])) {
356
357 1
            foreach ($positions as $key => $position) {
358
                // Get length of future block
359 1
                $length = isset($positions[$key + 1])
360 1
                    ? $positions[$key + 1] - $position + 1
361 1
                    : $count - $position;
362
363
                // Convert array to simple items
364 1
                $item = [];
365 1
                for ($i = 1; $i < $length; $i++) {
366 1
                    $item[] = array_shift($raw);
367
                }
368
369
                // Save as result
370 1
                $result[] = $this->parseResponse($item)[0];
371
            }
372
373
        } else {
374 4
            $result = $this->parseResponse($raw);
375
        }
376
377 4
        return $result;
378
    }
379
380
    /**
381
     * Parse response from Router OS
382
     *
383
     * @param array $response Response data
384
     *
385
     * @return array Array with parsed data
386
     */
387 5
    public function parseResponse(array $response): array
388
    {
389 5
        $result = [];
390 5
        $i      = -1;
391 5
        $lines  = count($response);
392 5
        foreach ($response as $key => $value) {
393 5
            switch ($value) {
394 5
                case '!re':
395 2
                    $i++;
396 2
                    break;
397 5
                case '!fatal':
398 1
                    $result = $response;
399 1
                    break 2;
400 4
                case '!trap':
401 4
                case '!done':
402
                    // Check for =ret=, .tag and any other following messages
403 4
                    for ($j = $key + 1; $j <= $lines; $j++) {
404
                        // If we have lines after current one
405 4
                        if (isset($response[$j])) {
406 3
                            $this->preParseResponse($response[$j], $result, $matches);
407
                        }
408
                    }
409 4
                    break 2;
410
                default:
411 2
                    $this->preParseResponse($value, $result, $matches, $i);
412 2
                    break;
413
            }
414
        }
415 5
        return $result;
416
    }
417
418
    /**
419
     * Response helper
420
     *
421
     * @param string     $value    Value which should be parsed
422
     * @param array      $result   Array with parsed response
423
     * @param array|null $matches  Matched words
424
     * @param string|int $iterator Type of iterations or number of item
425
     */
426 4
    private function preParseResponse(string $value, array &$result, ?array &$matches, $iterator = 'after'): void
427
    {
428 4
        $this->pregResponse($value, $matches);
429 4
        if (isset($matches[1][0], $matches[2][0])) {
430 4
            $result[$iterator][$matches[1][0]] = $matches[2][0];
431
        }
432 4
    }
433
434
    /**
435
     * Parse result from RouterOS by regular expression
436
     *
437
     * @param string     $value
438
     * @param array|null $matches
439
     */
440 9
    protected function pregResponse(string $value, ?array &$matches): void
441
    {
442 9
        preg_match_all('/^[=|.]([.\w-]+)=(.*)/', $value, $matches);
443 9
    }
444
445
    /**
446
     * Authorization logic
447
     *
448
     * @param bool $legacyRetry Retry login if we detect legacy version of RouterOS
449
     *
450
     * @return bool
451
     * @throws \RouterOS\Exceptions\ClientException
452
     * @throws \RouterOS\Exceptions\BadCredentialsException
453
     * @throws \RouterOS\Exceptions\ConfigException
454
     * @throws \RouterOS\Exceptions\QueryException
455
     */
456 26
    private function login(bool $legacyRetry = false): bool
457
    {
458
        // If legacy login scheme is enabled
459 26
        if ($this->config('legacy')) {
460
            // For the first we need get hash with salt
461 2
            $response = $this->query('/login')->read();
462
463
            // Now need use this hash for authorization
464 2
            $query = new Query('/login', [
465 2
                '=name=' . $this->config('user'),
466 2
                '=response=00' . md5(chr(0) . $this->config('pass') . pack('H*', $response['after']['ret'])),
467
            ]);
468
        } else {
469
            // Just login with our credentials
470 26
            $query = new Query('/login', [
471 26
                '=name=' . $this->config('user'),
472 26
                '=password=' . $this->config('pass'),
473
            ]);
474
475
            // If we set modern auth scheme but router with legacy firmware then need to retry query,
476
            // but need to prevent endless loop
477 26
            $legacyRetry = true;
478
        }
479
480
        // Execute query and get response
481 26
        $response = $this->query($query)->read(false);
482
483
        // if:
484
        //  - we have more than one response
485
        //  - response is '!done'
486
        // => problem with legacy version, swap it and retry
487
        // Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete?
488 26
        if ($legacyRetry && $this->isLegacy($response)) {
489 1
            $this->config->set('legacy', true);
490 1
            return $this->login();
491
        }
492
493
        // If RouterOS answered with invalid credentials then throw error
494 26
        if (!empty($response[0]) && '!trap' === $response[0]) {
495 1
            throw new BadCredentialsException('Invalid user name or password');
496
        }
497
498
        // Return true if we have only one line from server and this line is !done
499 26
        return (1 === count($response)) && isset($response[0]) && ('!done' === $response[0]);
500
    }
501
502
    /**
503
     * Detect by login request if firmware is legacy
504
     *
505
     * @param array $response
506
     *
507
     * @return bool
508
     * @throws \RouterOS\Exceptions\ConfigException
509
     */
510 26
    private function isLegacy(array $response): bool
511
    {
512 26
        return count($response) > 1 && '!done' === $response[0] && !$this->config('legacy');
513
    }
514
515
    /**
516
     * Connect to socket server
517
     *
518
     * @return bool
519
     * @throws \RouterOS\Exceptions\ClientException
520
     * @throws \RouterOS\Exceptions\ConfigException
521
     * @throws \RouterOS\Exceptions\QueryException
522
     */
523 26
    public function connect(): bool
524
    {
525
        // By default we not connected
526 26
        $connected = false;
527
528
        // Few attempts in loop
529 26
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
530
531
            // Initiate socket session
532 26
            $this->openSocket();
533
534
            // If socket is active
535 26
            if (null !== $this->getSocket()) {
536 26
                $this->connector = new APIConnector(new Streams\ResourceStream($this->getSocket()));
537
                // If we logged in then exit from loop
538 26
                if (true === $this->login()) {
539 26
                    $connected = true;
540 26
                    break;
541
                }
542
543
                // Else close socket and start from begin
544
                $this->closeSocket();
545
            }
546
547
            // Sleep some time between tries
548
            sleep($this->config('delay'));
549
        }
550
551
        // Return status of connection
552 26
        return $connected;
553
    }
554
555
    /**
556
     * Check if custom output is not empty
557
     *
558
     * @return bool
559
     */
560 26
    private function isCustomOutput(): bool
561
    {
562 26
        return null !== $this->customOutput;
563
    }
564
565
    /**
566
     * Execute export command on remote host, it also will be used
567
     * if "/export" command passed to query.
568
     *
569
     * @param string|null $arguments String with arguments which should be passed to export command
570
     *
571
     * @return string
572
     * @throws \RouterOS\Exceptions\ConfigException
573
     * @since 1.3.0
574
     */
575
    public function export(string $arguments = null): string
576
    {
577
        // Connect to remote host
578
        $connection =
579
            (new SSHConnection())
580
                ->timeout($this->config('timeout'))
581
                ->to($this->config('host'))
582
                ->onPort($this->config('ssh_port'))
583
                ->as($this->config('user') . '+etc')
584
                ->withPassword($this->config('pass'))
585
                ->connect();
586
587
        // Run export command
588
        $command = $connection->run('/export' . ' ' . $arguments);
589
590
        // Return the output
591
        return $command->getOutput();
592
    }
593
}
594