Completed
Push — master ( 45c272...07cbe0 )
by Mr
13s queued 10s
created

src/Client.php (1 issue)

Labels
Severity

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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