Completed
Pull Request — master (#40)
by Mr
05:34
created

Client::write()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 5.4042

Importance

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