Completed
Push — master ( 15d27a...559485 )
by Mr
04:51
created

Client::pregResponse()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 3
cts 3
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 2
crap 1
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
/**
11
 * Class Client for RouterOS management
12
 *
13
 * @package RouterOS
14
 * @since   0.1
15
 */
16
class Client implements Interfaces\ClientInterface
17
{
18
    use SocketTrait, ShortsTrait;
19
20
    /**
21
     * Configuration of connection
22
     *
23
     * @var \RouterOS\Config
24
     */
25
    private $_config;
26
27
    /**
28
     * API communication object
29
     *
30
     * @var \RouterOS\APIConnector
31
     */
32
33
    private $_connector;
34
35
    /**
36
     * Client constructor.
37
     *
38
     * @param array|\RouterOS\Config $config
39
     *
40
     * @throws \RouterOS\Exceptions\ClientException
41
     * @throws \RouterOS\Exceptions\ConfigException
42
     * @throws \RouterOS\Exceptions\QueryException
43
     */
44 10
    public function __construct($config)
45
    {
46
        // If array then need create object
47 10
        if (\is_array($config)) {
48 8
            $config = new Config($config);
49
        }
50
51
        // Check for important keys
52 10
        if (true !== $key = ArrayHelper::checkIfKeysNotExist(['host', 'user', 'pass'], $config->getParameters())) {
53 1
            throw new ConfigException("One or few parameters '$key' of Config is not set or empty");
54
        }
55
56
        // Save config if everything is okay
57 9
        $this->_config = $config;
58
59
        // Throw error if cannot to connect
60 9
        if (false === $this->connect()) {
61
            throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port'));
62
        }
63 2
    }
64
65
    /**
66
     * Get some parameter from config
67
     *
68
     * @param string $parameter Name of required parameter
69
     *
70
     * @return mixed
71
     * @throws \RouterOS\Exceptions\ConfigException
72
     */
73 9
    private function config(string $parameter)
74
    {
75 9
        return $this->_config->get($parameter);
76
    }
77
78
    /**
79
     * Send write query to RouterOS
80
     *
81
     * @param string|array|\RouterOS\Query $query
82
     *
83
     * @return \RouterOS\Client
84
     * @throws \RouterOS\Exceptions\QueryException
85
     * @deprecated
86
     * @codeCoverageIgnore
87
     */
88
    public function write($query): Client
89
    {
90
        if (\is_string($query)) {
91
            $query = new Query($query);
92
        } elseif (\is_array($query)) {
93
            $endpoint = array_shift($query);
94
            $query    = new Query($endpoint, $query);
95
        }
96
97
        if (!$query instanceof Query) {
98
            throw new QueryException('Parameters cannot be processed');
99
        }
100
101
        // Submit query to RouterOS
102
        return $this->writeRAW($query);
103
    }
104
105
    /**
106
     * Send write query to RouterOS (modern version of write)
107
     *
108
     * @param string|Query $endpoint   Path of API query or Query object
109
     * @param array|null   $where      List of where filters
110
     * @param string|null  $operations Some operations which need make on response
111
     * @param string|null  $tag        Mark query with tag
112
     *
113
     * @return \RouterOS\Client
114
     * @throws \RouterOS\Exceptions\QueryException
115
     * @throws \RouterOS\Exceptions\ClientException
116
     * @since 1.0.0
117
     */
118 2
    public function query($endpoint, array $where = null, string $operations = null, string $tag = null): Client
119
    {
120
        // If endpoint is string then build Query object
121 2
        $query = ($endpoint instanceof Query)
122 2
            ? $endpoint
123 2
            : new Query($endpoint);
124
125
        // Parse where array
126 2
        if (!empty($where)) {
127
128
            // If array is multidimensional, then parse each line
129
            if (is_array($where[0])) {
130
                foreach ($where as $item) {
131
132
                    // Null by default
133
                    $key      = null;
134
                    $operator = null;
135
                    $value    = null;
136
137 View Code Duplication
                    switch (\count($item)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
138
                        case 1:
139
                            list($key) = $item;
140
                            break;
141
                        case 2:
142
                            list($key, $operator) = $item;
143
                            break;
144
                        case 3:
145
                            list($key, $operator, $value) = $item;
146
                            break;
147
                        default:
148
                            throw new ClientException('From 1 to 3 parameters of "where" condition is allowed');
149
                    }
150
                    $query->where($key, $operator, $value);
151
                }
152
            } else {
153
                // Null by default
154
                $key      = null;
155
                $operator = null;
156
                $value    = null;
157
158 View Code Duplication
                switch (\count($where)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
159
                    case 1:
160
                        list($key) = $where;
161
                        break;
162
                    case 2:
163
                        list($key, $operator) = $where;
164
                        break;
165
                    case 3:
166
                        list($key, $operator, $value) = $where;
167
                        break;
168
                    default:
169
                        throw new ClientException('From 1 to 3 parameters of "where" condition is allowed');
170
                }
171
172
                $query->where($key, $operator, $value);
173
            }
174
175
        }
176
177
        // Append operations if set
178 2
        if (!empty($operations)) {
179
            $query->operations($operations);
180
        }
181
182
        // Append tag if set
183 2
        if (!empty($tag)) {
184
            $query->tag($tag);
185
        }
186
187
        // Submit query to RouterOS
188 2
        return $this->writeRAW($query);
189
    }
190
191
    /**
192
     * Send write query object to RouterOS
193
     *
194
     * @param \RouterOS\Query $query
195
     *
196
     * @return \RouterOS\Client
197
     * @throws \RouterOS\Exceptions\QueryException
198
     * @since 1.0.0
199
     */
200 2
    private function writeRAW(Query $query): Client
201
    {
202
        // Send commands via loop to router
203 2
        foreach ($query->getQuery() as $command) {
204 2
            $this->_connector->writeWord(trim($command));
205
        }
206
207
        // Write zero-terminator (empty string)
208 2
        $this->_connector->writeWord('');
209
210 2
        return $this;
211
    }
212
213
    /**
214
     * Read RAW response from RouterOS
215
     *
216
     * @return array
217
     * @since 1.0.0
218
     */
219 2
    private function readRAW(): array
220
    {
221
        // By default response is empty
222 2
        $response = [];
223
        // We have to wait a !done or !fatal
224 2
        $lastReply = false;
225
226
        // Read answer from socket in loop
227 2
        while (true) {
228 2
            $word = $this->_connector->readWord();
229
230 2
            if ('' === $word) {
231 2
                if ($lastReply) {
232
                    // We received a !done or !fatal message in a precedent loop
233
                    // response is complete
234 2
                    break;
235
                }
236
                // We did not receive the !done or !fatal message
237
                // This 0 length message is the end of a reply !re or !trap
238
                // We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message
239
                continue;
240
            }
241
242
            // Save output line to response array
243 2
            $response[] = $word;
244
245
            // If we get a !done or !fatal line in response, we are now ready to finish the read
246
            // but we need to wait a 0 length message, switch the flag
247 2
            if ('!done' === $word || '!fatal' === $word) {
248 2
                $lastReply = true;
249
            }
250
        }
251
252
        // Parse results and return
253 2
        return $response;
254
    }
255
256
    /**
257
     * Read answer from server after query was executed
258
     *
259
     * A Mikrotik reply is formed of blocks
260
     * Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal')
261
     * Each block end with an zero byte (empty line)
262
     * Reply ends with a complete !done or !fatal block (ended with 'empty line')
263
     * A !fatal block precedes TCP connexion close
264
     *
265
     * @param bool $parse
266
     *
267
     * @return mixed
268
     */
269 2
    public function read(bool $parse = true)
270
    {
271
        // Read RAW response
272 2
        $response = $this->readRAW();
273
274
        // Parse results and return
275 2
        return $parse ? $this->rosario($response) : $response;
276
    }
277
278
    /**
279
     * Read using Iterators to improve performance on large dataset
280
     *
281
     * @return \RouterOS\ResponseIterator
282
     * @since 1.0.0
283
     */
284
    public function readAsIterator(): ResponseIterator
285
    {
286
        return new ResponseIterator($this);
287
    }
288
289
    /**
290
     * This method was created by memory save reasons, it convert response
291
     * from RouterOS to readable array in safe way.
292
     *
293
     * @param array $raw Array RAW response from server
294
     *
295
     * @return mixed
296
     *
297
     * Based on RouterOSResponseArray solution by @arily
298
     *
299
     * @link    https://github.com/arily/RouterOSResponseArray
300
     * @since   1.0.0
301
     */
302 2
    private function rosario(array $raw): array
303
    {
304
        // This RAW should't be an error
305 2
        $positions = array_keys($raw, '!re');
306 2
        $count     = count($raw);
307 2
        $result    = [];
308
309 2
        if (isset($positions[1])) {
310
311
            foreach ($positions as $key => $position) {
312
                // Get length of future block
313
                $length = isset($positions[$key + 1])
314
                    ? $positions[$key + 1] - $position + 1
315
                    : $count - $position;
316
317
                // Convert array to simple items
318
                $item = [];
319
                for ($i = 1; $i < $length; $i++) {
320
                    $item[] = array_shift($raw);
321
                }
322
323
                // Save as result
324
                $result[] = $this->parseResponse($item)[0];
325
            }
326
327
        } else {
328 2
            $result = $this->parseResponse($raw);
329
        }
330
331 2
        return $result;
332
    }
333
334
    /**
335
     * Parse response from Router OS
336
     *
337
     * @param array $response Response data
338
     *
339
     * @return array Array with parsed data
340
     */
341 2
    public function parseResponse(array $response): array
342
    {
343 2
        $result = [];
344 2
        $i      = -1;
345 2
        $lines  = \count($response);
346 2
        foreach ($response as $key => $value) {
347 2
            switch ($value) {
348 2
                case '!re':
349
                    $i++;
350
                    break;
351 2
                case '!fatal':
352
                    $result = $response;
353
                    break 2;
354 2
                case '!trap':
355 2
                case '!done':
356
                    // Check for =ret=, .tag and any other following messages
357 2
                    for ($j = $key + 1; $j <= $lines; $j++) {
358
                        // If we have lines after current one
359 2
                        if (isset($response[$j])) {
360 2
                            $this->pregResponse($response[$j], $matches);
361 2 View Code Duplication
                            if (isset($matches[1][0], $matches[2][0])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
362 2
                                $result['after'][$matches[1][0]] = $matches[2][0];
363
                            }
364
                        }
365
                    }
366 2
                    break 2;
367
                default:
368
                    $this->pregResponse($value, $matches);
369 View Code Duplication
                    if (isset($matches[1][0], $matches[2][0])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
370
                        $result[$i][$matches[1][0]] = $matches[2][0];
371
                    }
372
                    break;
373
            }
374
        }
375 2
        return $result;
376
    }
377
378
    /**
379
     * Parse result from RouterOS by regular expression
380
     *
381
     * @param string $value
382
     * @param array  $matches
383
     */
384 2
    private function pregResponse(string $value, &$matches)
385
    {
386 2
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
387 2
    }
388
389
    /**
390
     * Authorization logic
391
     *
392
     * @param bool $legacyRetry Retry login if we detect legacy version of RouterOS
393
     *
394
     * @return bool
395
     * @throws \RouterOS\Exceptions\ClientException
396
     * @throws \RouterOS\Exceptions\ConfigException
397
     * @throws \RouterOS\Exceptions\QueryException
398
     */
399 2
    private function login(bool $legacyRetry = false): bool
400
    {
401
        // If legacy login scheme is enabled
402 2
        if ($this->config('legacy')) {
403
            // For the first we need get hash with salt
404 2
            $response = $this->query('/login')->read();
405
406
            // Now need use this hash for authorization
407 2
            $query = new Query('/login', [
408 2
                '=name=' . $this->config('user'),
409 2
                '=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret']))
410
            ]);
411
        } else {
412
            // Just login with our credentials
413 1
            $query = new Query('/login', [
414 1
                '=name=' . $this->config('user'),
415 1
                '=password=' . $this->config('pass')
416
            ]);
417
418
            // If we set modern auth scheme but router with legacy firmware then need to retry query,
419
            // but need to prevent endless loop
420 1
            $legacyRetry = true;
421
        }
422
423
        // Execute query and get response
424 2
        $response = $this->query($query)->read(false);
425
426
        // if:
427
        //  - we have more than one response
428
        //  - response is '!done'
429
        // => problem with legacy version, swap it and retry
430
        // Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete?
431 2
        if ($legacyRetry && $this->isLegacy($response)) {
432 1
            $this->_config->set('legacy', true);
433 1
            return $this->login();
434
        }
435
436
        // If RouterOS answered with invalid credentials then throw error
437 2
        if (!empty($response[0]) && $response[0] === '!trap') {
438
            throw new ClientException('Invalid user name or password');
439
        }
440
441
        // Return true if we have only one line from server and this line is !done
442 2
        return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done');
443
    }
444
445
    /**
446
     * Detect by login request if firmware is legacy
447
     *
448
     * @param array $response
449
     *
450
     * @return bool
451
     * @throws ConfigException
452
     */
453 1
    private function isLegacy(array &$response): bool
454
    {
455 1
        return \count($response) > 1 && $response[0] === '!done' && !$this->config('legacy');
456
    }
457
458
    /**
459
     * Connect to socket server
460
     *
461
     * @return bool
462
     * @throws \RouterOS\Exceptions\ClientException
463
     * @throws \RouterOS\Exceptions\ConfigException
464
     * @throws \RouterOS\Exceptions\QueryException
465
     */
466 9
    private function connect(): bool
467
    {
468
        // By default we not connected
469 9
        $connected = false;
470
471
        // Few attempts in loop
472 9
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
473
474
            // Initiate socket session
475 9
            $this->openSocket();
476
477
            // If socket is active
478 2
            if (null !== $this->getSocket()) {
479 2
                $this->_connector = new APIConnector(new Streams\ResourceStream($this->getSocket()));
480
                // If we logged in then exit from loop
481 2
                if (true === $this->login()) {
482 2
                    $connected = true;
483 2
                    break;
484
                }
485
486
                // Else close socket and start from begin
487
                $this->closeSocket();
488
            }
489
490
            // Sleep some time between tries
491
            sleep($this->config('delay'));
492
        }
493
494
        // Return status of connection
495 2
        return $connected;
496
    }
497
}
498