Completed
Pull Request — master (#33)
by Mr
08:17
created

Client::pregResponse()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 0
cts 3
cp 0
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 2
crap 2
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
use function array_keys;
11
use function array_shift;
12
use function chr;
13
use function count;
14
use function is_array;
15
use function is_string;
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
45
    private $_connector;
46
47
    /**
48
     * Client constructor.
49
     *
50
     * @param array|\RouterOS\Interfaces\ConfigInterface $config
51
     *
52
     * @throws \RouterOS\Exceptions\ClientException
53
     * @throws \RouterOS\Exceptions\ConfigException
54
     * @throws \RouterOS\Exceptions\QueryException
55
     */
56 14
    public function __construct($config)
57
    {
58
        // If array then need create object
59 14
        if (is_array($config)) {
60 12
            $config = new Config($config);
61
        }
62
63
        // Check for important keys
64 14
        if (true !== $key = ArrayHelper::checkIfKeysNotExist(['host', 'user', 'pass'], $config->getParameters())) {
65 1
            throw new ConfigException("One or few parameters '$key' of Config is not set or empty");
66
        }
67
68
        // Save config if everything is okay
69 13
        $this->_config = $config;
70
71
        // Throw error if cannot to connect
72 13
        if (false === $this->connect()) {
73
            throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port'));
74
        }
75 11
    }
76
77
    /**
78
     * Get some parameter from config
79
     *
80
     * @param string $parameter Name of required parameter
81
     *
82
     * @return mixed
83
     * @throws \RouterOS\Exceptions\ConfigException
84
     */
85 13
    private function config(string $parameter)
86
    {
87 13
        return $this->_config->get($parameter);
88
    }
89
90
    /**
91
     * Send write query to RouterOS
92
     *
93
     * @param string|array|Query $query
94
     *
95
     * @return \RouterOS\Client
96
     * @throws \RouterOS\Exceptions\QueryException
97
     * @deprecated
98
     */
99 5
    public function write($query): Client
100
    {
101 5
        if (is_string($query)) {
102 5
            $query = new Query($query);
103
        } elseif (is_array($query)) {
104
            $endpoint = array_shift($query);
105
            $query    = new Query($endpoint, $query);
106
        }
107
108 5
        if (!$query instanceof Query) {
109
            throw new QueryException('Parameters cannot be processed');
110
        }
111
112
        // Submit query to RouterOS
113 5
        return $this->writeRAW($query);
114
    }
115
116
    /**
117
     * Send write query to RouterOS (modern version of write)
118
     *
119
     * @param string|\RouterOS\Query $endpoint   Path of API query or Query object
120
     * @param array|null             $where      List of where filters
121
     * @param string|null            $operations Some operations which need make on response
122
     * @param string|null            $tag        Mark query with tag
123
     *
124
     * @return \RouterOS\Client
125
     * @throws \RouterOS\Exceptions\QueryException
126
     * @throws \RouterOS\Exceptions\ClientException
127
     * @since 1.0.0
128
     */
129 12
    public function query($endpoint, array $where = null, string $operations = null, string $tag = null): Client
130
    {
131
        // If endpoint is string then build Query object
132 12
        $query = ($endpoint instanceof Query)
133 12
            ? $endpoint
134 12
            : new Query($endpoint);
135
136
        // Parse where array
137 12
        if (!empty($where)) {
138
139
            // If array is multidimensional, then parse each line
140 2
            if (is_array($where[0])) {
141 1
                foreach ($where as $item) {
142 1
                    $query = $this->preQuery($item, $query);
143
                }
144
            } else {
145 1
                $query = $this->preQuery($where, $query);
146
            }
147
148
        }
149
150
        // Append operations if set
151 12
        if (!empty($operations)) {
152
            $query->operations($operations);
153
        }
154
155
        // Append tag if set
156 12
        if (!empty($tag)) {
157
            $query->tag($tag);
158
        }
159
160
        // Submit query to RouterOS
161 12
        return $this->writeRAW($query);
162
    }
163
164
    /**
165
     * Query helper
166
     *
167
     * @param array $item
168
     * @param Query $query
169
     *
170
     * @return \RouterOS\Query
171
     * @throws \RouterOS\Exceptions\ClientException
172
     * @throws \RouterOS\Exceptions\QueryException
173
     */
174 2
    private function preQuery(array $item, Query $query): Query
175
    {
176
        // Null by default
177 2
        $key      = null;
178 2
        $operator = null;
179 2
        $value    = null;
180
181 2
        switch (count($item)) {
182 2
            case 1:
183
                [$key] = $item;
184
                break;
185 2
            case 2:
186
                [$key, $operator] = $item;
187
                break;
188 2
            case 3:
189
                [$key, $operator, $value] = $item;
190
                break;
191
            default:
192 2
                throw new ClientException('From 1 to 3 parameters of "where" condition is allowed');
193
        }
194
195
        return $query->where($key, $operator, $value);
196
    }
197
198
    /**
199
     * Send write query object to RouterOS
200
     *
201
     * @param \RouterOS\Query $query
202
     *
203
     * @return \RouterOS\Client
204
     * @throws \RouterOS\Exceptions\QueryException
205
     * @since 1.0.0
206
     */
207 12
    private function writeRAW(Query $query): Client
208
    {
209
        // Send commands via loop to router
210 12
        foreach ($query->getQuery() as $command) {
211 12
            $this->_connector->writeWord(trim($command));
212
        }
213
214
        // Write zero-terminator (empty string)
215 12
        $this->_connector->writeWord('');
216
217 12
        return $this;
218
    }
219
220
    /**
221
     * Read RAW response from RouterOS
222
     *
223
     * @return array
224
     * @since 1.0.0
225
     */
226 12
    private function readRAW(): array
227
    {
228
        // By default response is empty
229 12
        $response = [];
230
        // We have to wait a !done or !fatal
231 12
        $lastReply = false;
232
233
        // Read answer from socket in loop
234 12
        while (true) {
235 12
            $word = $this->_connector->readWord();
236
237 12
            if ('' === $word) {
238 12
                if ($lastReply) {
239
                    // We received a !done or !fatal message in a precedent loop
240
                    // response is complete
241 12
                    break;
242
                }
243
                // We did not receive the !done or !fatal message
244
                // This 0 length message is the end of a reply !re or !trap
245
                // We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message
246 5
                continue;
247
            }
248
249
            // Save output line to response array
250 12
            $response[] = $word;
251
252
            // If we get a !done or !fatal line in response, we are now ready to finish the read
253
            // but we need to wait a 0 length message, switch the flag
254 12
            if ('!done' === $word || '!fatal' === $word) {
255 12
                $lastReply = true;
256
            }
257
        }
258
259
        // Parse results and return
260 12
        return $response;
261
    }
262
263
    /**
264
     * Read answer from server after query was executed
265
     *
266
     * A Mikrotik reply is formed of blocks
267
     * Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal')
268
     * Each block end with an zero byte (empty line)
269
     * Reply ends with a complete !done or !fatal block (ended with 'empty line')
270
     * A !fatal block precedes TCP connexion close
271
     *
272
     * @param bool $parse
273
     *
274
     * @return mixed
275
     */
276 12
    public function read(bool $parse = true)
277
    {
278
        // Read RAW response
279 12
        $response = $this->readRAW();
280
281
        // Parse results and return
282 12
        return $parse ? $this->rosario($response) : $response;
283
    }
284
285
    /**
286
     * Read using Iterators to improve performance on large dataset
287
     *
288
     * @return \RouterOS\ResponseIterator
289
     * @since 1.0.0
290
     */
291 4
    public function readAsIterator(): ResponseIterator
292
    {
293 4
        return new ResponseIterator($this);
294
    }
295
296
    /**
297
     * This method was created by memory save reasons, it convert response
298
     * from RouterOS to readable array in safe way.
299
     *
300
     * @param array $raw Array RAW response from server
301
     *
302
     * @return mixed
303
     *
304
     * Based on RouterOSResponseArray solution by @arily
305
     *
306
     * @link    https://github.com/arily/RouterOSResponseArray
307
     * @since   1.0.0
308
     */
309 1
    private function rosario(array $raw): array
310
    {
311
        // This RAW should't be an error
312 1
        $positions = array_keys($raw, '!re');
313 1
        $count     = count($raw);
314 1
        $result    = [];
315
316 1
        if (isset($positions[1])) {
317
318
            foreach ($positions as $key => $position) {
319
                // Get length of future block
320
                $length = isset($positions[$key + 1])
321
                    ? $positions[$key + 1] - $position + 1
322
                    : $count - $position;
323
324
                // Convert array to simple items
325
                $item = [];
326
                for ($i = 1; $i < $length; $i++) {
327
                    $item[] = array_shift($raw);
328
                }
329
330
                // Save as result
331
                $result[] = $this->parseResponse($item)[0];
332
            }
333
334
        } else {
335 1
            $result = $this->parseResponse($raw);
336
        }
337
338 1
        return $result;
339
    }
340
341
    /**
342
     * Parse response from Router OS
343
     *
344
     * @param array $response Response data
345
     *
346
     * @return array Array with parsed data
347
     */
348 2
    public function parseResponse(array $response): array
349
    {
350 2
        $result = [];
351 2
        $i      = -1;
352 2
        $lines  = count($response);
353 2
        foreach ($response as $key => $value) {
354 2
            switch ($value) {
355 2
                case '!re':
356 1
                    $i++;
357 1
                    break;
358 2
                case '!fatal':
359 1
                    $result = $response;
360 1
                    break 2;
361 1
                case '!trap':
362 1
                case '!done':
363
                    // Check for =ret=, .tag and any other following messages
364
                    for ($j = $key + 1; $j <= $lines; $j++) {
365
                        // If we have lines after current one
366
                        if (isset($response[$j])) {
367
                            $this->preParseResponse($response[$j], $result, $matches, $j);
368
                        }
369
                    }
370
                    break 2;
371
                default:
372 1
                    $this->preParseResponse($value, $result, $matches);
373
                    break;
374
            }
375
        }
376 1
        return $result;
377
    }
378
379
    /**
380
     * Response helper
381
     *
382
     * @param string     $value    Value which should be parsed
383
     * @param array      $result   Array with parsed response
384
     * @param array      $matches  Matched words
385
     * @param string|int $iterator Type of iterations or number of item
386
     */
387
    private function preParseResponse(string $value, array &$result, array &$matches, $iterator = 'after'): void
388
    {
389
        $this->pregResponse($value, $matches);
390
        if (isset($matches[1][0], $matches[2][0])) {
391
            $result[$iterator][$matches[1][0]] = $matches[2][0];
392
        }
393
    }
394
395
    /**
396
     * Parse result from RouterOS by regular expression
397
     *
398
     * @param string $value
399
     * @param array  $matches
400
     */
401
    private function pregResponse(string $value, array &$matches): void
402
    {
403
        preg_match_all('/^[=|.](.*)=(.*)/', $value, $matches);
404
    }
405
406
    /**
407
     * Authorization logic
408
     *
409
     * @param bool $legacyRetry Retry login if we detect legacy version of RouterOS
410
     *
411
     * @return bool
412
     * @throws \RouterOS\Exceptions\ClientException
413
     * @throws \RouterOS\Exceptions\ConfigException
414
     * @throws \RouterOS\Exceptions\QueryException
415
     */
416 12
    private function login(bool $legacyRetry = false): bool
417
    {
418
        // If legacy login scheme is enabled
419 12
        if ($this->config('legacy')) {
420
            // For the first we need get hash with salt
421
            $response = $this->query('/login')->read();
422
423
            // Now need use this hash for authorization
424
            $query = new Query('/login', [
425
                '=name=' . $this->config('user'),
426
                '=response=00' . md5(chr(0) . $this->config('pass') . pack('H*', $response['after']['ret']))
427
            ]);
428
        } else {
429
            // Just login with our credentials
430 12
            $query = new Query('/login', [
431 12
                '=name=' . $this->config('user'),
432 12
                '=password=' . $this->config('pass')
433
            ]);
434
435
            // If we set modern auth scheme but router with legacy firmware then need to retry query,
436
            // but need to prevent endless loop
437 12
            $legacyRetry = true;
438
        }
439
440
        // Execute query and get response
441 12
        $response = $this->query($query)->read(false);
442
443
        // if:
444
        //  - we have more than one response
445
        //  - response is '!done'
446
        // => problem with legacy version, swap it and retry
447
        // Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete?
448 12
        if ($legacyRetry && $this->isLegacy($response)) {
449
            $this->_config->set('legacy', true);
450
            return $this->login();
451
        }
452
453
        // If RouterOS answered with invalid credentials then throw error
454 12
        if (!empty($response[0]) && $response[0] === '!trap') {
455 1
            throw new ClientException('Invalid user name or password');
456
        }
457
458
        // Return true if we have only one line from server and this line is !done
459 11
        return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done');
460
    }
461
462
    /**
463
     * Detect by login request if firmware is legacy
464
     *
465
     * @param array $response
466
     *
467
     * @return bool
468
     * @throws \RouterOS\Exceptions\ConfigException
469
     */
470 12
    private function isLegacy(array &$response): bool
471
    {
472 12
        return count($response) > 1 && $response[0] === '!done' && !$this->config('legacy');
473
    }
474
475
    /**
476
     * Connect to socket server
477
     *
478
     * @return bool
479
     * @throws \RouterOS\Exceptions\ClientException
480
     * @throws \RouterOS\Exceptions\ConfigException
481
     * @throws \RouterOS\Exceptions\QueryException
482
     */
483 13
    private function connect(): bool
484
    {
485
        // By default we not connected
486 13
        $connected = false;
487
488
        // Few attempts in loop
489 13
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
490
491
            // Initiate socket session
492 13
            $this->openSocket();
493
494
            // If socket is active
495 12
            if (null !== $this->getSocket()) {
496 12
                $this->_connector = new APIConnector(new Streams\ResourceStream($this->getSocket()));
497
                // If we logged in then exit from loop
498 12
                if (true === $this->login()) {
499 11
                    $connected = true;
500 11
                    break;
501
                }
502
503
                // Else close socket and start from begin
504
                $this->closeSocket();
505
            }
506
507
            // Sleep some time between tries
508
            sleep($this->config('delay'));
509
        }
510
511
        // Return status of connection
512 11
        return $connected;
513
    }
514
}
515