Completed
Pull Request — master (#40)
by Mr
06:35
created

Client::read()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3.3332

Importance

Changes 0
Metric Value
dl 0
loc 14
ccs 4
cts 6
cp 0.6667
rs 9.7998
c 0
b 0
f 0
cc 3
nc 3
nop 1
crap 3.3332
1
<?php
2
3
namespace RouterOS;
4
5
use DivineOmega\SSHConnection\SSHConnection;
6
use RouterOS\Exceptions\ClientException;
7
use RouterOS\Exceptions\ConfigException;
8
use RouterOS\Interfaces\ClientInterface;
9
use RouterOS\Interfaces\QueryInterface;
10
use RouterOS\Helpers\ArrayHelper;
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 md5;
17
use function pack;
18
use function preg_match_all;
19
use function sleep;
20
use function trim;
21
22
/**
23
 * Class Client for RouterOS management
24
 *
25
 * @package RouterOS
26
 * @since   0.1
27
 */
28
class Client implements Interfaces\ClientInterface
29
{
30
    use SocketTrait, ShortsTrait;
31
32
    /**
33
     * Configuration of connection
34
     *
35
     * @var \RouterOS\Config
36
     */
37
    private $config;
38
39
    /**
40
     * API communication object
41
     *
42
     * @var \RouterOS\APIConnector
43
     */
44
    private $connector;
45
46
    /**
47
     * Some strings with custom output
48
     *
49
     * @var string
50
     */
51
    private $customOutput;
52
53
    /**
54
     * Client constructor.
55
     *
56
     * @param array|\RouterOS\Interfaces\ConfigInterface $config      Array with configuration or Config object
57
     * @param bool                                       $autoConnect If false it will skip auto-connect stage if not need to instantiate connection
58
     *
59
     * @throws \RouterOS\Exceptions\ClientException
60
     * @throws \RouterOS\Exceptions\ConfigException
61
     * @throws \RouterOS\Exceptions\QueryException
62
     */
63 9
    public function __construct($config, bool $autoConnect = true)
64
    {
65
        // If array then need create object
66 9
        if (is_array($config)) {
67 6
            $config = new Config($config);
68
        }
69
70
        // Check for important keys
71 9
        if (true !== $key = ArrayHelper::checkIfKeysNotExist(['host', 'user', 'pass'], $config->getParameters())) {
72 1
            throw new ConfigException("One or few parameters '$key' of Config is not set or empty");
73
        }
74
75
        // Save config if everything is okay
76 8
        $this->config = $config;
77
78
        // Skip next step if not need to instantiate connection
79 8
        if (false === $autoConnect) {
80
            return;
81
        }
82
83
        // Throw error if cannot to connect
84 8
        if (false === $this->connect()) {
85
            throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port'));
86
        }
87 6
    }
88
89
    /**
90
     * Get some parameter from config
91
     *
92
     * @param string $parameter Name of required parameter
93
     *
94
     * @return mixed
95
     * @throws \RouterOS\Exceptions\ConfigException
96
     */
97 8
    private function config(string $parameter)
98
    {
99 8
        return $this->config->get($parameter);
100
    }
101
102
    /**
103
     * Send write query to RouterOS (modern version of write)
104
     *
105
     * @param array|string|\RouterOS\Interfaces\QueryInterface $endpoint   Path of API query or Query object
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
     *
110
     * @return \RouterOS\Interfaces\ClientInterface
111
     * @throws \RouterOS\Exceptions\QueryException
112
     * @throws \RouterOS\Exceptions\ClientException
113
     * @throws \RouterOS\Exceptions\ConfigException
114
     * @since 1.0.0
115
     */
116 7
    public function query($endpoint, array $where = null, string $operations = null, string $tag = null): ClientInterface
117
    {
118
        // If endpoint is string then build Query object
119 7
        $query = ($endpoint instanceof Query)
120 7
            ? $endpoint
121 7
            : new Query($endpoint);
122
123
        // Parse where array
124 7
        if (!empty($where)) {
125
126
            // If array is multidimensional, then parse each line
127 1
            if (is_array($where[0])) {
128
                foreach ($where as $item) {
129
                    $query = $this->preQuery($item, $query);
130
                }
131
            } else {
132 1
                $query = $this->preQuery($where, $query);
133
            }
134
135
        }
136
137
        // Append operations if set
138 7
        if (!empty($operations)) {
139
            $query->operations($operations);
140
        }
141
142
        // Append tag if set
143 7
        if (!empty($tag)) {
144
            $query->tag($tag);
145
        }
146
147
        // Submit query to RouterOS
148 7
        return $this->writeRAW($query);
149
    }
150
151
    /**
152
     * Query helper
153
     *
154
     * @param array                               $item
155
     * @param \RouterOS\Interfaces\QueryInterface $query
156
     *
157
     * @return \RouterOS\Query
158
     * @throws \RouterOS\Exceptions\QueryException
159
     * @throws \RouterOS\Exceptions\ClientException
160
     */
161 1
    private function preQuery(array $item, QueryInterface $query): QueryInterface
162
    {
163
        // Null by default
164 1
        $key      = null;
165 1
        $operator = null;
166 1
        $value    = null;
167
168 1
        switch (count($item)) {
169 1
            case 1:
170 1
                [$key] = $item;
171 1
                break;
172
            case 2:
173
                [$key, $operator] = $item;
174
                break;
175
            case 3:
176
                [$key, $operator, $value] = $item;
177
                break;
178
            default:
179
                throw new ClientException('From 1 to 3 parameters of "where" condition is allowed');
180
                break;
0 ignored issues
show
Unused Code introduced by
break; does not seem to be reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
181
        }
182
183 1
        return $query->where($key, $operator, $value);
184
    }
185
186
    /**
187
     * Send write query object to RouterOS
188
     *
189
     * @param \RouterOS\Interfaces\QueryInterface $query
190
     *
191
     * @return \RouterOS\Interfaces\ClientInterface
192
     * @throws \RouterOS\Exceptions\QueryException
193
     * @throws \RouterOS\Exceptions\ConfigException
194
     * @since 1.0.0
195
     */
196 7
    private function writeRAW(QueryInterface $query): ClientInterface
197
    {
198 7
        $commands = $query->getQuery();
199
200
        // Check if first command is export
201 7
        if (strpos($commands[0], '/export') === 0) {
202
203
            // Convert export command with all arguments to valid SSH command
204
            $arguments = explode('/', $commands[0]);
205
            unset($arguments[1]);
206
            $arguments = implode(' ', $arguments);
207
208
            // Call the router via ssh and store output of export
209
            $this->customOutput = $this->export($arguments);
210
211
            // Return current object
212
            return $this;
213
        }
214
215
        // Send commands via loop to router
216 7
        foreach ($commands as $command) {
217 7
            $this->connector->writeWord(trim($command));
218
        }
219
220
        // Write zero-terminator (empty string)
221 7
        $this->connector->writeWord('');
222
223
        // Return current object
224 7
        return $this;
225
    }
226
227
    /**
228
     * Read RAW response from RouterOS, it can be /export command results also, not only array from API
229
     *
230
     * @return array|string
231
     * @since 1.0.0
232
     */
233 7
    private function readRAW()
234
    {
235
        // By default response is empty
236 7
        $response = [];
237
        // We have to wait a !done or !fatal
238 7
        $lastReply = false;
239
240
        // Convert strings to array and return results
241 7
        if ($this->isCustomOutput()) {
242
            // Return RAW configuration
243
            return $this->customOutput;
244
        }
245
246
        // Read answer from socket in loop
247 7
        while (true) {
248 7
            $word = $this->connector->readWord();
249
250 7
            if ('' === $word) {
251 7
                if ($lastReply) {
252
                    // We received a !done or !fatal message in a precedent loop
253
                    // response is complete
254 7
                    break;
255
                }
256
                // We did not receive the !done or !fatal message
257
                // This 0 length message is the end of a reply !re or !trap
258
                // We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message
259 2
                continue;
260
            }
261
262
            // Save output line to response array
263 7
            $response[] = $word;
264
265
            // If we get a !done or !fatal line in response, we are now ready to finish the read
266
            // but we need to wait a 0 length message, switch the flag
267 7
            if ('!done' === $word || '!fatal' === $word) {
268 7
                $lastReply = true;
269
            }
270
        }
271
272
        // Parse results and return
273 7
        return $response;
274
    }
275
276
    /**
277
     * Read answer from server after query was executed
278
     *
279
     * A Mikrotik reply is formed of blocks
280
     * Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal')
281
     * Each block end with an zero byte (empty line)
282
     * Reply ends with a complete !done or !fatal block (ended with 'empty line')
283
     * A !fatal block precedes TCP connexion close
284
     *
285
     * @param bool $parse If need parse output to array
286
     *
287
     * @return mixed
288
     */
289 7
    public function read(bool $parse = true)
290
    {
291
        // Read RAW response
292 7
        $response = $this->readRAW();
293
294
        // Return RAW configuration if custom output is set
295 7
        if ($this->isCustomOutput()) {
296
            $this->customOutput = null;
297
            return $response;
298
        }
299
300
        // Parse results and return
301 7
        return $parse ? $this->rosario($response) : $response;
0 ignored issues
show
Bug introduced by
It seems like $response defined by $this->readRAW() on line 292 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...
302
    }
303
304
    /**
305
     * Read using Iterators to improve performance on large dataset
306
     *
307
     * @return \RouterOS\ResponseIterator
308
     * @since 1.0.0
309
     */
310
    public function readAsIterator(): ResponseIterator
311
    {
312
        return new ResponseIterator($this);
313
    }
314
315
    /**
316
     * This method was created by memory save reasons, it convert response
317
     * from RouterOS to readable array in safe way.
318
     *
319
     * @param array $raw Array RAW response from server
320
     *
321
     * @return mixed
322
     *
323
     * Based on RouterOSResponseArray solution by @arily
324
     *
325
     * @link    https://github.com/arily/RouterOSResponseArray
326
     * @since   1.0.0
327
     */
328 3
    private function rosario(array $raw): array
329
    {
330
        // This RAW should't be an error
331 3
        $positions = array_keys($raw, '!re');
332 3
        $count     = count($raw);
333 3
        $result    = [];
334
335 3
        if (isset($positions[1])) {
336
337
            foreach ($positions as $key => $position) {
338
                // Get length of future block
339
                $length = isset($positions[$key + 1])
340
                    ? $positions[$key + 1] - $position + 1
341
                    : $count - $position;
342
343
                // Convert array to simple items
344
                $item = [];
345
                for ($i = 1; $i < $length; $i++) {
346
                    $item[] = array_shift($raw);
347
                }
348
349
                // Save as result
350
                $result[] = $this->parseResponse($item)[0];
351
            }
352
353
        } else {
354 3
            $result = $this->parseResponse($raw);
355
        }
356
357 3
        return $result;
358
    }
359
360
    /**
361
     * Parse response from Router OS
362
     *
363
     * @param array $response Response data
364
     *
365
     * @return array Array with parsed data
366
     */
367 3
    public function parseResponse(array $response): array
368
    {
369 3
        $result = [];
370 3
        $i      = -1;
371 3
        $lines  = count($response);
372 3
        foreach ($response as $key => $value) {
373 3
            switch ($value) {
374 3
                case '!re':
375 1
                    $i++;
376 1
                    break;
377 3
                case '!fatal':
378
                    $result = $response;
379
                    break 2;
380 3
                case '!trap':
381 3
                case '!done':
382
                    // Check for =ret=, .tag and any other following messages
383 3
                    for ($j = $key + 1; $j <= $lines; $j++) {
384
                        // If we have lines after current one
385 3
                        if (isset($response[$j])) {
386 2
                            $this->preParseResponse($response[$j], $result, $matches);
387
                        }
388
                    }
389 3
                    break 2;
390
                default:
391 1
                    $this->preParseResponse($value, $result, $matches, $i);
392 1
                    break;
393
            }
394
        }
395 3
        return $result;
396
    }
397
398
    /**
399
     * Response helper
400
     *
401
     * @param string     $value    Value which should be parsed
402
     * @param array      $result   Array with parsed response
403
     * @param null|array $matches  Matched words
404
     * @param string|int $iterator Type of iterations or number of item
405
     */
406 3
    private function preParseResponse(string $value, array &$result, ?array &$matches, $iterator = 'after'): void
407
    {
408 3
        $this->pregResponse($value, $matches);
409 3
        if (isset($matches[1][0], $matches[2][0])) {
410 3
            $result[$iterator][$matches[1][0]] = $matches[2][0];
411
        }
412 3
    }
413
414
    /**
415
     * Parse result from RouterOS by regular expression
416
     *
417
     * @param string     $value
418
     * @param null|array $matches
419
     */
420 3
    private function pregResponse(string $value, ?array &$matches): void
421
    {
422 3
        preg_match_all('/^[=|.](.*)=(.*)/', $value, $matches);
423 3
    }
424
425
    /**
426
     * Authorization logic
427
     *
428
     * @param bool $legacyRetry Retry login if we detect legacy version of RouterOS
429
     *
430
     * @return bool
431
     * @throws \RouterOS\Exceptions\ClientException
432
     * @throws \RouterOS\Exceptions\ConfigException
433
     * @throws \RouterOS\Exceptions\QueryException
434
     */
435 7
    private function login(bool $legacyRetry = false): bool
436
    {
437
        // If legacy login scheme is enabled
438 7
        if ($this->config('legacy')) {
439
            // For the first we need get hash with salt
440 2
            $response = $this->query('/login')->read();
441
442
            // Now need use this hash for authorization
443 2
            $query = new Query('/login', [
444 2
                '=name=' . $this->config('user'),
445 2
                '=response=00' . md5(chr(0) . $this->config('pass') . pack('H*', $response['after']['ret']))
446
            ]);
447
        } else {
448
            // Just login with our credentials
449 6
            $query = new Query('/login', [
450 6
                '=name=' . $this->config('user'),
451 6
                '=password=' . $this->config('pass')
452
            ]);
453
454
            // If we set modern auth scheme but router with legacy firmware then need to retry query,
455
            // but need to prevent endless loop
456 6
            $legacyRetry = true;
457
        }
458
459
        // Execute query and get response
460 7
        $response = $this->query($query)->read(false);
461
462
        // if:
463
        //  - we have more than one response
464
        //  - response is '!done'
465
        // => problem with legacy version, swap it and retry
466
        // Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete?
467 7
        if ($legacyRetry && $this->isLegacy($response)) {
468 1
            $this->config->set('legacy', true);
469 1
            return $this->login();
470
        }
471
472
        // If RouterOS answered with invalid credentials then throw error
473 7
        if (!empty($response[0]) && $response[0] === '!trap') {
474 1
            throw new ClientException('Invalid user name or password');
475
        }
476
477
        // Return true if we have only one line from server and this line is !done
478 6
        return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done');
479
    }
480
481
    /**
482
     * Detect by login request if firmware is legacy
483
     *
484
     * @param array $response
485
     *
486
     * @return bool
487
     * @throws \RouterOS\Exceptions\ConfigException
488
     */
489 6
    private function isLegacy(array $response): bool
490
    {
491 6
        return count($response) > 1 && $response[0] === '!done' && !$this->config('legacy');
492
    }
493
494
    /**
495
     * Connect to socket server
496
     *
497
     * @return bool
498
     * @throws \RouterOS\Exceptions\ClientException
499
     * @throws \RouterOS\Exceptions\ConfigException
500
     * @throws \RouterOS\Exceptions\QueryException
501
     */
502 8
    public function connect(): bool
503
    {
504
        // By default we not connected
505 8
        $connected = false;
506
507
        // Few attempts in loop
508 8
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
509
510
            // Initiate socket session
511 8
            $this->openSocket();
512
513
            // If socket is active
514 7
            if (null !== $this->getSocket()) {
515 7
                $this->connector = new APIConnector(new Streams\ResourceStream($this->getSocket()));
516
                // If we logged in then exit from loop
517 7
                if (true === $this->login()) {
518 6
                    $connected = true;
519 6
                    break;
520
                }
521
522
                // Else close socket and start from begin
523
                $this->closeSocket();
524
            }
525
526
            // Sleep some time between tries
527
            sleep($this->config('delay'));
528
        }
529
530
        // Return status of connection
531 6
        return $connected;
532
    }
533
534
    /**
535
     * Check if custom output is not empty
536
     *
537
     * @return bool
538
     */
539 7
    private function isCustomOutput(): bool
540
    {
541 7
        return $this->customOutput !== null;
542
    }
543
544
    /**
545
     * Execute export command on remote host, it also will be used
546
     * if "/export" command passed to query.
547
     *
548
     * @param string|null $arguments String with arguments which should be passed to export command
549
     *
550
     * @return string
551
     * @throws \RouterOS\Exceptions\ConfigException
552
     * @since 1.3.0
553
     */
554
    public function export(string $arguments = null): string
555
    {
556
        // Connect to remote host
557
        $connection =
558
            (new SSHConnection())
559
                ->timeout($this->config('timeout'))
560
                ->to($this->config('host'))
561
                ->onPort($this->config('ssh_port'))
562
                ->as($this->config('user') . '+etc')
563
                ->withPassword($this->config('pass'))
564
                ->connect();
565
566
        // Run export command
567
        $command = $connection->run('/export' . ' ' . $arguments);
568
569
        // Return the output
570
        return $command->getOutput();
571
    }
572
}
573