Completed
Push — master ( c09486...c4a020 )
by Mr
03:43
created

Client::read()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 8
ccs 3
cts 3
cp 1
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 1
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
/**
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
     */
84 15
    public function write($query): Client
85
    {
86 15
        if (\is_string($query)) {
87 8
            $query = new Query($query);
88 15
        } elseif (\is_array($query)) {
89 1
            $endpoint = array_shift($query);
90 1
            $query    = new Query($endpoint, $query);
91
        }
92
93 15
        if (!$query instanceof Query) {
94 1
            throw new QueryException('Parameters cannot be processed');
95
        }
96
97
        // Submit query to RouterOS
98 15
        return $this->writeRAW($query);
99
    }
100
101
    /**
102
     * Send write query to RouterOS (modern version of write)
103
     *
104
     * @param string      $endpoint   Path of API query
105
     * @param array|null  $where      List of where filters
106
     * @param string|null $operations Some operations which need make on response
107
     * @param string|null $tag        Mark query with tag
108
     * @return \RouterOS\Client
109
     * @throws \RouterOS\Exceptions\QueryException
110
     * @since 1.0.0
111
     */
112
    public function query(string $endpoint, array $where = null, string $operations = null, string $tag = null): Client
113
    {
114
        // If endpoint is string then build Query object
115
        $query = new Query($endpoint);
116
117
        // Parse where array
118
        if (!empty($where)) {
119
120
            // If array is multidimensional, then parse each line
121
            if (is_array($where[0])) {
122
                foreach ($where as [$key, $operator, $value]) {
123
                    $query->where($key, $operator, $value);
0 ignored issues
show
Bug introduced by
The variable $key does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $operator does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $value does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
124
                }
125
            } else {
126
                $query->where($where[0], $where[1] ?? null, $where[2] ?? null);
127
            }
128
129
        }
130
131
        // Append operations if set
132
        if (!empty($operations)) {
133
            $query->operations($operations);
134
        }
135
136
        // Append tag if set
137
        if (!empty($tag)) {
138
            $query->tag($tag);
139
        }
140
141
        // Submit query to RouterOS
142
        return $this->writeRAW($query);
143
    }
144
145
    /**
146
     * Send write query object to RouterOS
147
     *
148
     * @param \RouterOS\Query $query
149
     * @return \RouterOS\Client
150
     * @throws \RouterOS\Exceptions\QueryException
151
     * @since 1.0.0
152
     */
153 15
    private function writeRAW(Query $query): Client
154
    {
155
        // Send commands via loop to router
156 15
        foreach ($query->getQuery() as $command) {
157 15
            $this->_connector->writeWord(trim($command));
158
        }
159
160
        // Write zero-terminator (empty string)
161 15
        $this->_connector->writeWord('');
162
163 15
        return $this;
164
    }
165
166
    /**
167
     * Read RAW response from RouterOS
168
     *
169
     * @return array
170
     * @since 1.0.0
171
     */
172 15
    private function readRAW(): array
173
    {
174
        // By default response is empty
175 15
        $response = [];
176
        // We have to wait a !done or !fatal
177 15
        $lastReply = false;
178
179
        // Read answer from socket in loop
180 15
        while (true) {
181 15
            $word = $this->_connector->readWord();
182
183 15
            if ('' === $word) {
184 15
                if ($lastReply) {
185
                    // We received a !done or !fatal message in a precedent loop
186
                    // response is complete
187 15
                    break;
188
                }
189
                // We did not receive the !done or !fatal message
190
                // This 0 length message is the end of a reply !re or !trap
191
                // We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message
192 7
                continue;
193
            }
194
195
            // Save output line to response array
196 15
            $response[] = $word;
197
198
            // If we get a !done or !fatal line in response, we are now ready to finish the read
199
            // but we need to wait a 0 length message, switch the flag
200 15
            if ('!done' === $word || '!fatal' === $word) {
201 15
                $lastReply = true;
202
            }
203
        }
204
205
        // Parse results and return
206 15
        return $response;
207
    }
208
209
    /**
210
     * Read answer from server after query was executed
211
     *
212
     * A Mikrotik reply is formed of blocks
213
     * Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal')
214
     * Each block end with an zero byte (empty line)
215
     * Reply ends with a complete !done or !fatal block (ended with 'empty line')
216
     * A !fatal block precedes TCP connexion close
217
     *
218
     * @param bool $parse
219
     * @return mixed
220
     */
221 15
    public function read(bool $parse = true)
222
    {
223
        // Read RAW response
224 15
        $response = $this->readRAW();
225
226
        // Parse results and return
227 15
        return $parse ? $this->rosario($response) : $response;
228
    }
229
230
    /**
231
     * Read using Iterators to improve performance on large dataset
232
     *
233
     * @return \RouterOS\ResponseIterator
234
     * @since 1.0.0
235
     */
236 4
    public function readAsIterator(): ResponseIterator
237
    {
238 4
        return new ResponseIterator($this);
239
    }
240
241
    /**
242
     * This method was created by memory save reasons, it convert response
243
     * from RouterOS to readable array in safe way.
244
     *
245
     * @param array $raw Array RAW response from server
246
     * @return mixed
247
     *
248
     * Based on RouterOSResponseArray solution by @arily
249
     *
250
     * @link    https://github.com/arily/RouterOSResponseArray
251
     * @since   1.0.0
252
     */
253 4
    private function rosario(array $raw): array
254
    {
255
        // This RAW should't be an error
256 4
        $positions = array_keys($raw, '!re');
257 4
        $count     = count($raw);
258 4
        $result    = [];
259
260 4
        if (isset($positions[1])) {
261
262 1
            foreach ($positions as $key => $position) {
263
                // Get length of future block
264 1
                $length = isset($positions[$key + 1])
265 1
                    ? $positions[$key + 1] - $position + 1
266 1
                    : $count - $position;
267
268
                // Convert array to simple items
269 1
                $item = [];
270 1
                for ($i = 1; $i < $length; $i++) {
271 1
                    $item[] = array_shift($raw);
272
                }
273
274
                // Save as result
275 1
                $result[] = $this->parseResponse($item)[0];
276
            }
277
278
        } else {
279 4
            $result = $this->parseResponse($raw);
280
        }
281
282 4
        return $result;
283
    }
284
285
    /**
286
     * Parse response from Router OS
287
     *
288
     * @param array $response Response data
289
     * @return array Array with parsed data
290
     */
291 5
    public function parseResponse(array $response): array
292
    {
293 5
        $result = [];
294 5
        $i      = -1;
295 5
        $lines  = \count($response);
296 5
        foreach ($response as $key => $value) {
297
            switch ($value) {
298 5
                case '!re':
299 2
                    $i++;
300 2
                    break;
301 5
                case '!fatal':
302 1
                    $result = $response;
303 1
                    break 2;
304 4
                case '!trap':
305 4
                case '!done':
306
                    // Check for =ret=, .tag and any other following messages
307 4
                    for ($j = $key + 1; $j <= $lines; $j++) {
308
                        // If we have lines after current one
309 4
                        if (isset($response[$j])) {
310 2
                            $this->pregResponse($response[$j], $matches);
311 2
                            if (isset($matches[1][0], $matches[2][0])) {
312 2
                                $result['after'][$matches[1][0]] = $matches[2][0];
313
                            }
314
                        }
315
                    }
316 4
                    break 2;
317
                default:
318 2
                    $this->pregResponse($value, $matches);
319 2
                    if (isset($matches[1][0], $matches[2][0])) {
320 2
                        $result[$i][$matches[1][0]] = $matches[2][0];
321
                    }
322 2
                    break;
323
            }
324
        }
325 5
        return $result;
326
    }
327
328
    /**
329
     * Parse result from RouterOS by regular expression
330
     *
331
     * @param string $value
332
     * @param array  $matches
333
     */
334 4
    private function pregResponse(string $value, &$matches)
335
    {
336 4
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
337 4
    }
338
339
    /**
340
     * Authorization logic
341
     *
342
     * @param bool $legacyRetry Retry login if we detect legacy version of RouterOS
343
     * @return bool
344
     * @throws \RouterOS\Exceptions\ClientException
345
     * @throws \RouterOS\Exceptions\ConfigException
346
     * @throws \RouterOS\Exceptions\QueryException
347
     */
348 15
    private function login(bool $legacyRetry = false): bool
349
    {
350
        // If legacy login scheme is enabled
351 15
        if ($this->config('legacy')) {
352
            // For the first we need get hash with salt
353 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...
354
355
            // Now need use this hash for authorization
356 2
            $query = new Query('/login', [
357 2
                '=name=' . $this->config('user'),
358 2
                '=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret']))
359
            ]);
360
        } else {
361
            // Just login with our credentials
362 14
            $query = new Query('/login', [
363 14
                '=name=' . $this->config('user'),
364 14
                '=password=' . $this->config('pass')
365
            ]);
366
367
            // If we set modern auth scheme but router with legacy firmware then need to retry query,
368
            // but need to prevent endless loop
369 14
            $legacyRetry = true;
370
        }
371
372
        // Execute query and get response
373 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...
374
375
        // if:
376
        //  - we have more than one response
377
        //  - response is '!done'
378
        // => problem with legacy version, swap it and retry
379
        // Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete?
380 15
        if ($legacyRetry && $this->isLegacy($response)) {
381 1
            $this->_config->set('legacy', true);
382 1
            return $this->login();
383
        }
384
385
        // Return true if we have only one line from server and this line is !done
386 15
        return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done');
387
    }
388
389
    /**
390
     * Detect by login request if firmware is legacy
391
     *
392
     * @param array $response
393
     * @return bool
394
     * @throws ConfigException
395
     */
396 14
    private function isLegacy(array &$response): bool
397
    {
398 14
        return \count($response) > 1 && $response[0] === '!done' && !$this->config('legacy');
399
    }
400
401
    /**
402
     * Connect to socket server
403
     *
404
     * @return bool
405
     * @throws \RouterOS\Exceptions\ClientException
406
     * @throws \RouterOS\Exceptions\ConfigException
407
     * @throws \RouterOS\Exceptions\QueryException
408
     */
409 16
    private function connect(): bool
410
    {
411
        // By default we not connected
412 16
        $connected = false;
413
414
        // Few attempts in loop
415 16
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
416
417
            // Initiate socket session
418 16
            $this->openSocket();
419
420
            // If socket is active
421 15
            if (null !== $this->getSocket()) {
422 15
                $this->_connector = new APIConnector(new Streams\ResourceStream($this->getSocket()));
423
                // If we logged in then exit from loop
424 15
                if (true === $this->login()) {
425 14
                    $connected = true;
426 14
                    break;
427
                }
428
429
                // Else close socket and start from begin
430 1
                $this->closeSocket();
431
            }
432
433
            // Sleep some time between tries
434 1
            sleep($this->config('delay'));
435
        }
436
437
        // Return status of connection
438 15
        return $connected;
439
    }
440
}
441