Completed
Push — master ( 18dd6f...ca205d )
by Mr
03:47
created

Client::write()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
dl 0
loc 16
ccs 0
cts 0
cp 0
rs 9.7333
c 0
b 0
f 0
cc 4
nc 6
nop 1
crap 20
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
     * @throws \RouterOS\Exceptions\ClientException
40
     * @throws \RouterOS\Exceptions\ConfigException
41
     * @throws \RouterOS\Exceptions\QueryException
42
     */
43 17
    public function __construct($config)
44
    {
45
        // If array then need create object
46 17
        if (\is_array($config)) {
47 14
            $config = new Config($config);
48
        }
49
50
        // Check for important keys
51 17
        if (true !== $key = ArrayHelper::checkIfKeysNotExist(['host', 'user', 'pass'], $config->getParameters())) {
52 1
            throw new ConfigException("One or few parameters '$key' of Config is not set or empty");
53
        }
54
55
        // Save config if everything is okay
56 16
        $this->_config = $config;
57
58
        // Throw error if cannot to connect
59 16
        if (false === $this->connect()) {
60 1
            throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port'));
61
        }
62 14
    }
63
64
    /**
65
     * Get some parameter from config
66
     *
67
     * @param string $parameter Name of required parameter
68
     * @return mixed
69
     * @throws \RouterOS\Exceptions\ConfigException
70
     */
71 16
    private function config(string $parameter)
72
    {
73 16
        return $this->_config->get($parameter);
74
    }
75
76
    /**
77
     * Send write query to RouterOS
78
     *
79
     * @param string|array|\RouterOS\Query $query
80
     * @return \RouterOS\Client
81
     * @throws \RouterOS\Exceptions\QueryException
82
     * @deprecated
83
     * @codeCoverageIgnore
84
     */
85
    public function write($query): Client
86
    {
87
        if (\is_string($query)) {
88
            $query = new Query($query);
89
        } elseif (\is_array($query)) {
90
            $endpoint = array_shift($query);
91
            $query    = new Query($endpoint, $query);
92
        }
93
94
        if (!$query instanceof Query) {
95
            throw new QueryException('Parameters cannot be processed');
96
        }
97
98
        // Submit query to RouterOS
99
        return $this->writeRAW($query);
100
    }
101
102
    /**
103
     * Send write query to RouterOS (modern version of write)
104
     *
105
     * @param string|Query $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
     * @return \RouterOS\Client
110
     * @throws \RouterOS\Exceptions\QueryException
111
     * @throws \RouterOS\Exceptions\ClientException
112
     * @since 1.0.0
113
     */
114 4
    public function query($endpoint, array $where = null, string $operations = null, string $tag = null): Client
115
    {
116
        // If endpoint is string then build Query object
117 4
        $query = ($endpoint instanceof Query)
118
            ? $endpoint
119 4
            : new Query($endpoint);
120
121
        // Parse where array
122 4
        if (!empty($where)) {
123
124
            // If array is multidimensional, then parse each line
125 3
            if (is_array($where[0])) {
126 2
                foreach ($where as $item) {
127
128
                    // Null by default
129 2
                    $key      = null;
130 2
                    $operator = null;
131 2
                    $value    = null;
132
133 2 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...
134 2
                        case 1:
135 1
                            list($key) = $item;
136 1
                            break;
137 2
                        case 2:
138 1
                            list($key, $operator) = $item;
139 1
                            break;
140 2
                        case 3:
141 1
                            list($key, $operator, $value) = $item;
142 1
                            break;
143
                        default:
144 1
                            throw new ClientException('From 1 to 3 parameters of "where" condition is allowed');
145
                    }
146 1
                    $query->where($key, $operator, $value);
147
                }
148
            } else {
149
                // Null by default
150 2
                $key      = null;
151 2
                $operator = null;
152 2
                $value    = null;
153
154 2 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...
155 2
                    case 1:
156 1
                        list($key) = $where;
157 1
                        break;
158 2
                    case 2:
159 1
                        list($key, $operator) = $where;
160 1
                        break;
161 2
                    case 3:
162 1
                        list($key, $operator, $value) = $where;
163 1
                        break;
164
                    default:
165 1
                        throw new ClientException('From 1 to 3 parameters of "where" condition is allowed');
166
                }
167
168 1
                $query->where($key, $operator, $value);
169
            }
170
171
        }
172
173
        // Append operations if set
174 2
        if (!empty($operations)) {
175 1
            $query->operations($operations);
176
        }
177
178
        // Append tag if set
179 2
        if (!empty($tag)) {
180 1
            $query->tag($tag);
181
        }
182
183
        // Submit query to RouterOS
184 2
        return $this->writeRAW($query);
185
    }
186
187
    /**
188
     * Send write query object to RouterOS
189
     *
190
     * @param \RouterOS\Query $query
191
     * @return \RouterOS\Client
192
     * @throws \RouterOS\Exceptions\QueryException
193
     * @since 1.0.0
194
     */
195 15
    private function writeRAW(Query $query): Client
196
    {
197
        // Send commands via loop to router
198 15
        foreach ($query->getQuery() as $command) {
199 15
            $this->_connector->writeWord(trim($command));
200
        }
201
202
        // Write zero-terminator (empty string)
203 15
        $this->_connector->writeWord('');
204
205 15
        return $this;
206
    }
207
208
    /**
209
     * Read RAW response from RouterOS
210
     *
211
     * @return array
212
     * @since 1.0.0
213
     */
214 15
    private function readRAW(): array
215
    {
216
        // By default response is empty
217 15
        $response = [];
218
        // We have to wait a !done or !fatal
219 15
        $lastReply = false;
220
221
        // Read answer from socket in loop
222 15
        while (true) {
223 15
            $word = $this->_connector->readWord();
224
225 15
            if ('' === $word) {
226 15
                if ($lastReply) {
227
                    // We received a !done or !fatal message in a precedent loop
228
                    // response is complete
229 15
                    break;
230
                }
231
                // We did not receive the !done or !fatal message
232
                // This 0 length message is the end of a reply !re or !trap
233
                // We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message
234 6
                continue;
235
            }
236
237
            // Save output line to response array
238 15
            $response[] = $word;
239
240
            // If we get a !done or !fatal line in response, we are now ready to finish the read
241
            // but we need to wait a 0 length message, switch the flag
242 15
            if ('!done' === $word || '!fatal' === $word) {
243 15
                $lastReply = true;
244
            }
245
        }
246
247
        // Parse results and return
248 15
        return $response;
249
    }
250
251
    /**
252
     * Read answer from server after query was executed
253
     *
254
     * A Mikrotik reply is formed of blocks
255
     * Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal')
256
     * Each block end with an zero byte (empty line)
257
     * Reply ends with a complete !done or !fatal block (ended with 'empty line')
258
     * A !fatal block precedes TCP connexion close
259
     *
260
     * @param bool $parse
261
     * @return mixed
262
     */
263 15
    public function read(bool $parse = true)
264
    {
265
        // Read RAW response
266 15
        $response = $this->readRAW();
267
268
        // Parse results and return
269 15
        return $parse ? $this->rosario($response) : $response;
270
    }
271
272
    /**
273
     * Read using Iterators to improve performance on large dataset
274
     *
275
     * @return \RouterOS\ResponseIterator
276
     * @since 1.0.0
277
     */
278 4
    public function readAsIterator(): ResponseIterator
279
    {
280 4
        return new ResponseIterator($this);
281
    }
282
283
    /**
284
     * This method was created by memory save reasons, it convert response
285
     * from RouterOS to readable array in safe way.
286
     *
287
     * @param array $raw Array RAW response from server
288
     * @return mixed
289
     *
290
     * Based on RouterOSResponseArray solution by @arily
291
     *
292
     * @link    https://github.com/arily/RouterOSResponseArray
293
     * @since   1.0.0
294
     */
295 4
    private function rosario(array $raw): array
296
    {
297
        // This RAW should't be an error
298 4
        $positions = array_keys($raw, '!re');
299 4
        $count     = count($raw);
300 4
        $result    = [];
301
302 4
        if (isset($positions[1])) {
303
304 1
            foreach ($positions as $key => $position) {
305
                // Get length of future block
306 1
                $length = isset($positions[$key + 1])
307 1
                    ? $positions[$key + 1] - $position + 1
308 1
                    : $count - $position;
309
310
                // Convert array to simple items
311 1
                $item = [];
312 1
                for ($i = 1; $i < $length; $i++) {
313 1
                    $item[] = array_shift($raw);
314
                }
315
316
                // Save as result
317 1
                $result[] = $this->parseResponse($item)[0];
318
            }
319
320
        } else {
321 4
            $result = $this->parseResponse($raw);
322
        }
323
324 4
        return $result;
325
    }
326
327
    /**
328
     * Parse response from Router OS
329
     *
330
     * @param array $response Response data
331
     * @return array Array with parsed data
332
     */
333 5
    public function parseResponse(array $response): array
334
    {
335 5
        $result = [];
336 5
        $i      = -1;
337 5
        $lines  = \count($response);
338 5
        foreach ($response as $key => $value) {
339
            switch ($value) {
340 5
                case '!re':
341 2
                    $i++;
342 2
                    break;
343 5
                case '!fatal':
344 1
                    $result = $response;
345 1
                    break 2;
346 4
                case '!trap':
347 4
                case '!done':
348
                    // Check for =ret=, .tag and any other following messages
349 4
                    for ($j = $key + 1; $j <= $lines; $j++) {
350
                        // If we have lines after current one
351 4
                        if (isset($response[$j])) {
352 2
                            $this->pregResponse($response[$j], $matches);
353 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...
354 2
                                $result['after'][$matches[1][0]] = $matches[2][0];
355
                            }
356
                        }
357
                    }
358 4
                    break 2;
359
                default:
360 2
                    $this->pregResponse($value, $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[$i][$matches[1][0]] = $matches[2][0];
363
                    }
364 2
                    break;
365
            }
366
        }
367 5
        return $result;
368
    }
369
370
    /**
371
     * Parse result from RouterOS by regular expression
372
     *
373
     * @param string $value
374
     * @param array  $matches
375
     */
376 4
    private function pregResponse(string $value, &$matches)
377
    {
378 4
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
379 4
    }
380
381
    /**
382
     * Authorization logic
383
     *
384
     * @param bool $legacyRetry Retry login if we detect legacy version of RouterOS
385
     * @return bool
386
     * @throws \RouterOS\Exceptions\ClientException
387
     * @throws \RouterOS\Exceptions\ConfigException
388
     * @throws \RouterOS\Exceptions\QueryException
389
     */
390 15
    private function login(bool $legacyRetry = false): bool
391
    {
392
        // If legacy login scheme is enabled
393 15
        if ($this->config('legacy')) {
394
            // For the first we need get hash with salt
395 2
            $response = $this->write('/login')->read();
0 ignored issues
show
Deprecated Code introduced by
The method RouterOS\Client::write() has been deprecated.

This method has been deprecated.

Loading history...
396
397
            // Now need use this hash for authorization
398 2
            $query = new Query('/login', [
399 2
                '=name=' . $this->config('user'),
400 2
                '=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret']))
401
            ]);
402
        } else {
403
            // Just login with our credentials
404 14
            $query = new Query('/login', [
405 14
                '=name=' . $this->config('user'),
406 14
                '=password=' . $this->config('pass')
407
            ]);
408
409
            // If we set modern auth scheme but router with legacy firmware then need to retry query,
410
            // but need to prevent endless loop
411 14
            $legacyRetry = true;
412
        }
413
414
        // Execute query and get response
415 15
        $response = $this->write($query)->read(false);
0 ignored issues
show
Deprecated Code introduced by
The method RouterOS\Client::write() has been deprecated.

This method has been deprecated.

Loading history...
416
417
        // if:
418
        //  - we have more than one response
419
        //  - response is '!done'
420
        // => problem with legacy version, swap it and retry
421
        // Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete?
422 15
        if ($legacyRetry && $this->isLegacy($response)) {
423 1
            $this->_config->set('legacy', true);
424 1
            return $this->login();
425
        }
426
427
        // Return true if we have only one line from server and this line is !done
428 15
        return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done');
429
    }
430
431
    /**
432
     * Detect by login request if firmware is legacy
433
     *
434
     * @param array $response
435
     * @return bool
436
     * @throws ConfigException
437
     */
438 14
    private function isLegacy(array &$response): bool
439
    {
440 14
        return \count($response) > 1 && $response[0] === '!done' && !$this->config('legacy');
441
    }
442
443
    /**
444
     * Connect to socket server
445
     *
446
     * @return bool
447
     * @throws \RouterOS\Exceptions\ClientException
448
     * @throws \RouterOS\Exceptions\ConfigException
449
     * @throws \RouterOS\Exceptions\QueryException
450
     */
451 16
    private function connect(): bool
452
    {
453
        // By default we not connected
454 16
        $connected = false;
455
456
        // Few attempts in loop
457 16
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
458
459
            // Initiate socket session
460 16
            $this->openSocket();
461
462
            // If socket is active
463 15
            if (null !== $this->getSocket()) {
464 15
                $this->_connector = new APIConnector(new Streams\ResourceStream($this->getSocket()));
465
                // If we logged in then exit from loop
466 15
                if (true === $this->login()) {
467 14
                    $connected = true;
468 14
                    break;
469
                }
470
471
                // Else close socket and start from begin
472 1
                $this->closeSocket();
473
            }
474
475
            // Sleep some time between tries
476 1
            sleep($this->config('delay'));
477
        }
478
479
        // Return status of connection
480 15
        return $connected;
481
    }
482
}
483