Completed
Push — master ( 952e4e...00a84b )
by Mr
12s
created

Client::isLegacy()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 2
cts 2
cp 1
rs 10
c 0
b 0
f 0
cc 3
nc 3
nop 1
crap 3
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;
19
20
    /**
21
     * Socket resource
22
     *
23
     * @var resource|null
24
     */
25
    private $_socket;
26
27
    /**
28
     * Code of error
29
     *
30
     * @var int
31
     */
32
    private $_socket_err_num;
33
34
    /**
35
     * Description of socket error
36
     *
37
     * @var string
38
     */
39
    private $_socket_err_str;
40
41
    /**
42
     * Configuration of connection
43
     *
44
     * @var \RouterOS\Config
45
     */
46
    private $_config;
47
48
    /**
49
     * Client constructor.
50
     *
51
     * @param   array|\RouterOS\Config $config
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 3
            $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->setConfig($config);
70
71
        // Throw error if cannot to connect
72 13
        if (false === $this->connect()) {
73 1
            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
     * @return  mixed
82
     * @throws  \RouterOS\Exceptions\ConfigException
83
     */
84 13
    private function config(string $parameter)
85
    {
86 13
        return $this->_config->get($parameter);
87
    }
88
89
    /**
90
     * Return socket resource if is exist
91
     *
92
     * @return  \RouterOS\Config
93
     * @since   0.6
94
     */
95 1
    public function getConfig(): Config
96
    {
97 1
        return $this->_config;
98
    }
99
100
    /**
101
     * Set configuration of client
102
     *
103
     * @param   \RouterOS\Config $config
104
     * @since   0.7
105
     */
106 13
    public function setConfig(Config $config)
107
    {
108 13
        $this->_config = $config;
109 13
    }
110
111
    /**
112
     * Encode given length in RouterOS format
113
     *
114
     * @param   string $string
115
     * @return  string Encoded length
116
     * @throws  \RouterOS\Exceptions\ClientException
117
     */
118 12
    private function encodeLength(string $string): string
119
    {
120 12
        $length = \strlen($string);
121
122 12
        if ($length < 128) {
123 12
            $orig_length = $length;
124 12
            $offset      = -1;
125
        } elseif ($length < 16384) {
126
            $orig_length = $length | 0x8000;
127
            $offset      = -2;
128
        } elseif ($length < 2097152) {
129
            $orig_length = $length | 0xC00000;
130
            $offset      = -3;
131
        } elseif ($length < 268435456) {
132
            $orig_length = $length | 0xE0000000;
133
            $offset      = -4;
134
        } else {
135
            throw new ClientException("Unable to encode length of '$string'");
136
        }
137
138
        // Pack string to binary format
139 12
        $result = pack('I*', $orig_length);
140
        // Parse binary string to array
141 12
        $result = str_split($result);
142
        // Reverse array
143 12
        $result = array_reverse($result);
144
        // Extract values from offset to end of array
145 12
        $result = \array_slice($result, $offset);
146
147
        // Sew items into one line
148 12
        $output = null;
149 12
        foreach ($result as $item) {
150 12
            $output .= $item;
151
        }
152
153 12
        return $output;
154
    }
155
156
    /**
157
     * Read length of line
158
     *
159
     * @param   int $byte
160
     * @return  int
161
     */
162 12
    private function getLength(int $byte): int
163
    {
164
        // If the first bit is set then we need to remove the first four bits, shift left 8
165
        // and then read another byte in.
166
        // We repeat this for the second and third bits.
167
        // If the fourth bit is set, we need to remove anything left in the first byte
168
        // and then read in yet another byte.
169 12
        if ($byte & 128) {
170
            if (($byte & 192) === 128) {
171
                $length = (($byte & 63) << 8) + \ord(fread($this->_socket, 1));
172
            } elseif (($byte & 224) === 192) {
173
                $length = (($byte & 31) << 8) + \ord(fread($this->_socket, 1));
174
                $length = ($length << 8) + \ord(fread($this->_socket, 1));
175
            } elseif (($byte & 240) === 224) {
176
                $length = (($byte & 15) << 8) + \ord(fread($this->_socket, 1));
177
                $length = ($length << 8) + \ord(fread($this->_socket, 1));
178
                $length = ($length << 8) + \ord(fread($this->_socket, 1));
179
            } else {
180
                $length = \ord(fread($this->_socket, 1));
181
                $length = ($length << 8) + \ord(fread($this->_socket, 1)) * 3;
182
                $length = ($length << 8) + \ord(fread($this->_socket, 1));
183
                $length = ($length << 8) + \ord(fread($this->_socket, 1));
184
            }
185
        } else {
186 12
            $length = $byte;
187
        }
188 12
        return $length;
189
    }
190
191
    /**
192
     * Send write query to RouterOS (with or without tag)
193
     *
194
     * @param   string|array|\RouterOS\Query $query
195
     * @return  \RouterOS\Client
196
     * @throws  \RouterOS\Exceptions\ClientException
197
     * @throws  \RouterOS\Exceptions\QueryException
198
     */
199 12
    public function write($query): Client
200
    {
201 12
        if (\is_string($query)) {
202 2
            $query = new Query($query);
203 12
        } elseif (\is_array($query)) {
204 1
            $endpoint = array_shift($query);
205 1
            $query    = new Query($endpoint, $query);
206
        }
207
208 12
        if (!$query instanceof Query) {
209 1
            throw new QueryException('Parameters cannot be processed');
210
        }
211
212
        // Send commands via loop to router
213 12
        foreach ($query->getQuery() as $command) {
214 12
            $command = trim($command);
215 12
            fwrite($this->_socket, $this->encodeLength($command) . $command);
216
        }
217
218
        // Write zero-terminator
219 12
        fwrite($this->_socket, \chr(0));
220
221 12
        return $this;
222
    }
223
224
    /**
225
     * Read answer from server after query was executed
226
     *
227
     * A Mikrotik reply is formed of blocks
228
     * Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal')
229
     * Each block end with an zero byte (empty line)
230
     * Reply ends with a complete !done or !fatal block (ended with 'empty line')
231
     * A !fatal block precedes TCP connexion close
232
     * 
233
     * @param   bool $parse
234
     * @return  array
235
     */
236 12
    public function read(bool $parse = true): array
237
    {
238
        // By default response is empty
239 12
        $response = [];
240
        // We have to wait a !done or !fatal 
241 12
        $lastReply = false;
242
243
        // Read answer from socket in loop
244 12
        while (true) {
245
            // Read the first byte of input which gives us some or all of the length
246
            // of the remaining reply.
247 12
            $byte   = fread($this->_socket, 1);
248 12
            $length = $this->getLength(\ord($byte));
249
250 12
            if ($length == 0) {
251 12
                if ($lastReply) {
252
                    // We received a !done or !fatal message in a precedent loop
253
                    // response is complete
254 12
                    break;
255
                }
256
                // We did not receive the !done or !fatal message
257
                // This 0 length message is the end of a reply !re or !trap
258
                // We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message
259 4
                continue;
260
            }
261
262
            // Save output line to response array
263 12
            $response[] = $line =  stream_get_contents($this->_socket, $length);
264
265
            // If we get a !done or !fatal line in response, we are now ready to finish the read
266
            // but we need to wait a 0 length message, switch the flag
267 12
            if ('!done' === $line || '!fatal' === $line) {
268 12
                $lastReply = true;                
269
            }
270
        }
271
272
        // Parse results and return
273 12
        return $parse ? $this->parseResponse($response) : $response;
274
    }
275
276
    /**
277
     * Alias for ->write() method
278
     *
279
     * @param   string|array|\RouterOS\Query $query
280
     * @return  \RouterOS\Client
281
     * @throws  \RouterOS\Exceptions\ClientException
282
     * @throws  \RouterOS\Exceptions\QueryException
283
     */
284 1
    public function w($query): Client
285
    {
286 1
        return $this->write($query);
287
    }
288
289
    /**
290
     * Alias for ->read() method
291
     *
292
     * @param   bool $parse
293
     * @return  array
294
     * @since   0.7
295
     */
296 1
    public function r(bool $parse = true): array
297
    {
298 1
        return $this->read($parse);
299
    }
300
301
    /**
302
     * Alias for ->write()->read() combination of methods
303
     *
304
     * @param   string|array|\RouterOS\Query $query
305
     * @param   bool                         $parse
306
     * @return  array
307
     * @throws  \RouterOS\Exceptions\ClientException
308
     * @throws  \RouterOS\Exceptions\QueryException
309
     * @since   0.6
310
     */
311 4
    public function wr($query, bool $parse = true): array
312
    {
313 4
        return $this->write($query)->read($parse);
314
    }
315
316
    /**
317
     * Parse response from Router OS
318
     *
319
     * @param   array $response Response data
320
     * @return  array Array with parsed data
321
     */
322 4
    private function parseResponse(array $response): array
323
    {
324 4
        $result = [];
325 4
        $i      = -1;
326 4
        $lines  = \count($response);
327 4
        foreach ($response as $key => $value) {
328 4
            switch ($value) {
329 4
                case '!re':
330 1
                    $i++;
331 1
                    break;
332 4
                case '!fatal':
333 1
                    $result = $response;
334 1
                    break 2;
335 3
                case '!trap':
336 3
                case '!done':
337
                    // Check for =ret=, .tag and any other following messages
338 3
                    for ($j = $key + 1; $j <= $lines; $j++) {
339
                        // If we have lines after current one
340 3
                        if (isset($response[$j])) {
341 2
                            $this->pregResponse($response[$j], $matches);
342 2
                            if (!empty($matches)) {
343 2
                                $result['after'][$matches[1][0]] = $matches[2][0];
344
                            }
345
                        }
346
                    }
347 3
                    break 2;
348
                default:
349 1
                    $this->pregResponse($value, $matches);
350 1
                    if (!empty($matches)) {
351 1
                        $result[$i][$matches[1][0]] = $matches[2][0];
352
                    }
353 1
                    break;
354
            }
355
        }
356 4
        return $result;
357
    }
358
359
    /**
360
     * Parse result from RouterOS by regular expression
361
     *
362
     * @param   string $value
363
     * @param   array  $matches
364
     */
365 3
    private function pregResponse(string $value, &$matches)
366
    {
367 3
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
368 3
    }
369
370
    /**
371
     * Authorization logic
372
     *
373
     * @param   bool $legacyRetry Retry login if we detect legacy version of RouterOS
374
     * @return  bool
375
     * @throws  \RouterOS\Exceptions\ClientException
376
     * @throws  \RouterOS\Exceptions\ConfigException
377
     * @throws  \RouterOS\Exceptions\QueryException
378
     */
379 12
    private function login(bool $legacyRetry = false): bool
380
    {
381
        // If legacy login scheme is enabled
382 12
        if ($this->config('legacy')) {
383
            // For the first we need get hash with salt
384 2
            $query    = new Query('/login');
385 2
            $response = $this->write($query)->read();
386
387
            // Now need use this hash for authorization
388 2
            $query = (new Query('/login'))
389 2
                ->add('=name=' . $this->config('user'))
390 2
                ->add('=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret'])));
391
        } else {
392
            // Just login with our credentials
393 11
            $query = (new Query('/login'))
394 11
                ->add('=name=' . $this->config('user'))
395 11
                ->add('=password=' . $this->config('pass'));
396
397
            // If we set modern auth scheme but router with legacy firmware then need to retry query,
398
            // but need to prevent endless loop
399 11
            $legacyRetry = true;
400
        }
401
402
        // Execute query and get response
403 12
        $response = $this->write($query)->read(false);
404
405
        // if:
406
        //  - we have more than one response
407
        //  - response is '!done'
408
        // => problem with legacy version, swap it and retry
409
        // Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete?
410 12
        if ($legacyRetry && $this->isLegacy($response)) {
411 1
            $this->_config->set('legacy', true);
412 1
            return $this->login();
413
        }
414
415
        // Return true if we have only one line from server and this line is !done
416 12
        return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done');
417
    }
418
419
    /**
420
     * Detect by login request if firmware is legacy
421
     *
422
     * @param   array $response
423
     * @return  bool
424
     * @throws  ConfigException
425
     */
426 11
    private function isLegacy(array &$response): bool
427
    {
428 11
        return \count($response) > 1 && $response[0] === '!done' && !$this->config('legacy');
429
    }
430
431
    /**
432
     * Connect to socket server
433
     *
434
     * @return  bool
435
     * @throws  \RouterOS\Exceptions\ClientException
436
     * @throws  \RouterOS\Exceptions\ConfigException
437
     * @throws  \RouterOS\Exceptions\QueryException
438
     */
439 13
    private function connect(): bool
440
    {
441
        // By default we not connected
442 13
        $connected = false;
443
444
        // Few attempts in loop
445 13
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
446
447
            // Initiate socket session
448 13
            $this->openSocket();
449
450
            // If socket is active
451 12
            if (null !== $this->getSocket()) {
452
453
                // If we logged in then exit from loop
454 12
                if (true === $this->login()) {
455 11
                    $connected = true;
456 11
                    break;
457
                }
458
459
                // Else close socket and start from begin
460 1
                $this->closeSocket();
461
            }
462
463
            // Sleep some time between tries
464 1
            sleep($this->config('delay'));
465
        }
466
467
        // Return status of connection
468 12
        return $connected;
469
    }
470
471
}
472