Completed
Pull Request — master (#4)
by Mr
05:10
created

Client::wr()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 2
cts 2
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;
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
     * @param   bool $parse
228
     * @return  array
229
     */
230 12
    public function read(bool $parse = true): array
231
    {
232
        // By default response is empty
233 12
        $response = [];
234
235
        // Read answer from socket in loop
236 12
        while (true) {
237
            // Read the first byte of input which gives us some or all of the length
238
            // of the remaining reply.
239 12
            $byte   = fread($this->_socket, 1);
240 12
            $length = $this->getLength(\ord($byte));
241
242
            // Save only non empty strings
243 12
            if ($length > 0) {
244
                // Save output line to response array
245 12
                $response[] = stream_get_contents($this->_socket, $length);
246
            }
247
248
            // If we get a !done line in response, change state of $isDone variable
249 12
            $isDone = ('!done' === end($response));
250
251
            // Get status about latest operation
252 12
            $status = stream_get_meta_data($this->_socket);
253
254
            // If we do not have unread bytes from socket or <-same and if done, then exit from loop
255 12
            if ((!$status['unread_bytes']) || (!$status['unread_bytes'] && $isDone)) {
256 12
                break;
257
            }
258
        }
259
260
        // Parse results and return
261 12
        return $parse ? $this->parseResponse($response) : $response;
262
    }
263
264
    /**
265
     * Alias for ->write() method
266
     *
267
     * @param   string|array|\RouterOS\Query $query
268
     * @return  \RouterOS\Client
269
     * @throws  \RouterOS\Exceptions\ClientException
270
     * @throws  \RouterOS\Exceptions\QueryException
271
     */
272 1
    public function w($query): Client
273
    {
274 1
        return $this->write($query);
275
    }
276
277
    /**
278
     * Alias for ->read() method
279
     *
280
     * @param   bool $parse
281
     * @return  array
282
     * @since   0.7
283
     */
284 1
    public function r(bool $parse = true): array
285
    {
286 1
        return $this->read($parse);
287
    }
288
289
    /**
290
     * Alias for ->write()->read() combination of methods
291
     *
292
     * @param   string|array|\RouterOS\Query $query
293
     * @param   bool                         $parse
294
     * @return  array
295
     * @throws  \RouterOS\Exceptions\ClientException
296
     * @throws  \RouterOS\Exceptions\QueryException
297
     * @since   0.6
298
     */
299 4
    public function wr($query, bool $parse = true): array
300
    {
301 4
        return $this->write($query)->read($parse);
302
    }
303
304
    /**
305
     * Parse response from Router OS
306
     *
307
     * @param   array $response Response data
308
     * @return  array Array with parsed data
309
     */
310 4
    private function parseResponse(array $response): array
311
    {
312 4
        $result = [];
313 4
        $i      = -1;
314 4
        $lines  = \count($response);
315 4
        foreach ($response as $key => $value) {
316
            switch ($value) {
317 4
                case '!re':
318 1
                    $i++;
319 1
                    break;
320 4
                case '!fatal':
321 1
                    $result = $response;
322 1
                    break 2;
323 3
                case '!trap':
324 3
                case '!done':
325
                    // Check for =ret=, .tag and any other following messages
326 3
                    for ($j = $key + 1; $j <= $lines; $j++) {
327
                        // If we have lines after current one
328 3
                        if (isset($response[$j])) {
329 2
                            $this->pregResponse($response[$j], $matches);
330 2
                            if (!empty($matches)) {
331 2
                                $result['after'][$matches[1][0]] = $matches[2][0];
332
                            }
333
                        }
334
                    }
335 3
                    break 2;
336
                default:
337 1
                    $this->pregResponse($value, $matches);
338 1
                    if (!empty($matches)) {
339 1
                        $result[$i][$matches[1][0]] = $matches[2][0];
340
                    }
341 1
                    break;
342
            }
343
        }
344 4
        return $result;
345
    }
346
347
    /**
348
     * Parse result from RouterOS by regular expression
349
     *
350
     * @param   string $value
351
     * @param   array  $matches
352
     */
353 3
    private function pregResponse(string $value, &$matches)
354
    {
355 3
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
356 3
    }
357
358
    /**
359
     * Authorization logic
360
     *
361
     * @param   bool $legacyRetry Retry login if we detect legacy version of RouterOS
362
     * @return  bool
363
     * @throws  \RouterOS\Exceptions\ClientException
364
     * @throws  \RouterOS\Exceptions\ConfigException
365
     * @throws  \RouterOS\Exceptions\QueryException
366
     */
367 12
    private function login(bool $legacyRetry = false): bool
368
    {
369
        // If legacy login scheme is enabled
370 12
        if ($this->config('legacy')) {
371
            // For the first we need get hash with salt
372 2
            $query    = new Query('/login');
373 2
            $response = $this->write($query)->read();
374
375
            // Now need use this hash for authorization
376 2
            $query = (new Query('/login'))
377 2
                ->add('=name=' . $this->config('user'))
378 2
                ->add('=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret'])));
379
        } else {
380
            // Just login with our credentials
381 11
            $query = (new Query('/login'))
382 11
                ->add('=name=' . $this->config('user'))
383 11
                ->add('=password=' . $this->config('pass'));
384
385
            // If we set modern auth scheme but router with legacy firmware then need to retry query,
386
            // but need to prevent endless loop
387 11
            $legacyRetry = true;
388
        }
389
390
        // Execute query and get response
391 12
        $response = $this->write($query)->read(false);
392
393
        // if:
394
        //  - we have more than one response
395
        //  - response is '!done'
396
        // => problem with legacy version, swap it and retry
397
        // Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete?
398 12
        if ($legacyRetry && $this->isLegacy($response)) {
399 1
            $this->_config->set('legacy', true);
400 1
            return $this->login();
401
        }
402
403
        // Return true if we have only one line from server and this line is !done
404 12
        return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done');
405
    }
406
407
    /**
408
     * Detect by login request if firmware is legacy
409
     *
410
     * @param   array $response
411
     * @return  bool
412
     * @throws  ConfigException
413
     */
414 11
    private function isLegacy(array &$response): bool
415
    {
416 11
        return \count($response) > 1 && $response[0] === '!done' && !$this->config('legacy');
417
    }
418
419
    /**
420
     * Connect to socket server
421
     *
422
     * @return  bool
423
     * @throws  \RouterOS\Exceptions\ClientException
424
     * @throws  \RouterOS\Exceptions\ConfigException
425
     * @throws  \RouterOS\Exceptions\QueryException
426
     */
427 13
    private function connect(): bool
428
    {
429
        // By default we not connected
430 13
        $connected = false;
431
432
        // Few attempts in loop
433 13
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
434
435
            // Initiate socket session
436 13
            $this->openSocket();
437
438
            // If socket is active
439 12
            if (null !== $this->getSocket()) {
440
441
                // If we logged in then exit from loop
442 12
                if (true === $this->login()) {
443 11
                    $connected = true;
444 11
                    break;
445
                }
446
447
                // Else close socket and start from begin
448 1
                $this->closeSocket();
449
            }
450
451
            // Sleep some time between tries
452 1
            sleep($this->config('delay'));
453
        }
454
455
        // Return status of connection
456 12
        return $connected;
457
    }
458
459
}
460