Completed
Push — master ( 4dcdcf...d95589 )
by Mr
03:36
created

Client::__construct()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3.1406

Importance

Changes 0
Metric Value
dl 0
loc 18
ccs 6
cts 8
cp 0.75
rs 9.6666
c 0
b 0
f 0
cc 3
nc 4
nop 1
crap 3.1406
1
<?php
2
3
namespace RouterOS;
4
5
use RouterOS\Exceptions\ClientException;
6
use RouterOS\Exceptions\ConfigException;
7
8
/**
9
 * Class Client for RouterOS management
10
 *
11
 * @package RouterOS
12
 * @since   0.1
13
 */
14
class Client implements Interfaces\ClientInterface
15
{
16
    /**
17
     * Socket resource
18
     *
19
     * @var resource|null
20
     */
21
    private $_socket;
22
23
    /**
24
     * Code of error
25
     *
26
     * @var int
27
     */
28
    private $_socket_err_num;
29
30
    /**
31
     * Description of socket error
32
     *
33
     * @var string
34
     */
35
    private $_socket_err_str;
36
37
    /**
38
     * Configuration of connection
39
     *
40
     * @var \RouterOS\Config
41
     */
42
    private $_config;
43
44
    /**
45
     * Client constructor.
46
     *
47
     * @param   array|\RouterOS\Config $config
48
     * @throws  \RouterOS\Exceptions\ClientException
49
     * @throws  \RouterOS\Exceptions\ConfigException
50
     * @throws  \RouterOS\Exceptions\QueryException
51
     */
52 5
    public function __construct($config)
53
    {
54
        // If array then need create object
55 5
        if (\is_array($config)) {
56
            $config = new Config($config);
57
        }
58
59
        // Check for important keys
60 5
        $this->exceptionIfKeyNotExist(['host', 'user', 'pass'], $config);
61
62
        // Save config if everything is okay
63 5
        $this->setConfig($config);
64
65
        // Throw error if cannot to connect
66 5
        if (false === $this->connect()) {
67
            throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port'));
68
        }
69 4
    }
70
71
    /**
72
     * Check for important keys
73
     *
74
     * @param   array            $keys
75
     * @param   \RouterOS\Config $config
76
     * @throws  \RouterOS\Exceptions\ConfigException
77
     */
78 5
    private function exceptionIfKeyNotExist(array $keys, Config $config)
79
    {
80 5
        $parameters = $config->getParameters();
81 5
        foreach ($keys as $key) {
82 5
            if (!array_key_exists($key, $parameters) && isset($parameters[$key])) {
83
                throw new ConfigException("Parameter '$key' of Config is not set or empty");
84
            }
85
        }
86 5
    }
87
88
    /**
89
     * Get some parameter from config
90
     *
91
     * @param   string $parameter Name of required parameter
92
     * @return  mixed
93
     * @throws  \RouterOS\Exceptions\ConfigException
94
     */
95 5
    private function config(string $parameter)
96
    {
97 5
        return $this->_config->get($parameter);
98
    }
99
100
    /**
101
     * Return socket resource if is exist
102
     *
103
     * @return  \RouterOS\Config
104
     * @since   0.6
105
     */
106
    public function getConfig(): Config
107
    {
108
        return $this->_config;
109
    }
110
111
    /**
112
     * Set configuration of client
113
     *
114
     * @param   \RouterOS\Config $config
115
     * @since   0.7
116
     */
117 5
    public function setConfig(Config $config)
118
    {
119 5
        $this->_config = $config;
120 5
    }
121
122
    /**
123
     * Encode given length in RouterOS format
124
     *
125
     * @param   string $string
126
     * @return  string Encoded length
127
     * @throws  \RouterOS\Exceptions\ClientException
128
     */
129 4
    private function encodeLength(string $string): string
130
    {
131 4
        $length = \strlen($string);
132
133 4
        if ($length < 128) {
134 4
            $orig_length = $length;
135 4
            $offset      = -1;
136
        } elseif ($length < 16384) {
137
            $orig_length = $length | 0x8000;
138
            $offset      = -2;
139
        } elseif ($length < 2097152) {
140
            $orig_length = $length | 0xC00000;
141
            $offset      = -3;
142
        } elseif ($length < 268435456) {
143
            $orig_length = $length | 0xE0000000;
144
            $offset      = -4;
145
        } else {
146
            throw new ClientException("Unable to encode length of '$string'");
147
        }
148
149
        // Pack string to binary format
150 4
        $result = pack('I*', $orig_length);
151
        // Parse binary string to array
152 4
        $result = str_split($result);
153
        // Reverse array
154 4
        $result = array_reverse($result);
155
        // Extract values from offset to end of array
156 4
        $result = \array_slice($result, $offset);
157
158
        // Sew items into one line
159 4
        $output = null;
160 4
        foreach ($result as $item) {
161 4
            $output .= $item;
162
        }
163
164 4
        return $output;
165
    }
166
167
    /**
168
     * Read length of line
169
     *
170
     * @param   int $byte
171
     * @return  int
172
     */
173 4
    private function getLength(int $byte): int
174
    {
175
        // If the first bit is set then we need to remove the first four bits, shift left 8
176
        // and then read another byte in.
177
        // We repeat this for the second and third bits.
178
        // If the fourth bit is set, we need to remove anything left in the first byte
179
        // and then read in yet another byte.
180 4
        if ($byte & 128) {
181
            if (($byte & 192) === 128) {
182
                $length = (($byte & 63) << 8) + \ord(fread($this->_socket, 1));
183
            } else {
184
                if (($byte & 224) === 192) {
185
                    $length = (($byte & 31) << 8) + \ord(fread($this->_socket, 1));
186
                    $length = ($length << 8) + \ord(fread($this->_socket, 1));
187
                } else {
188
                    if (($byte & 240) === 224) {
189
                        $length = (($byte & 15) << 8) + \ord(fread($this->_socket, 1));
190
                        $length = ($length << 8) + \ord(fread($this->_socket, 1));
191
                        $length = ($length << 8) + \ord(fread($this->_socket, 1));
192
                    } else {
193
                        $length = \ord(fread($this->_socket, 1));
194
                        $length = ($length << 8) + \ord(fread($this->_socket, 1)) * 3;
195
                        $length = ($length << 8) + \ord(fread($this->_socket, 1));
196
                        $length = ($length << 8) + \ord(fread($this->_socket, 1));
197
                    }
198
                }
199
            }
200
        } else {
201 4
            $length = $byte;
202
        }
203 4
        return $length;
204
    }
205
206
    /**
207
     * Send write query to RouterOS (with or without tag)
208
     *
209
     * @param   \RouterOS\Query $query
210
     * @return  \RouterOS\Client
211
     * @throws  \RouterOS\Exceptions\ClientException
212
     * @throws  \RouterOS\Exceptions\QueryException
213
     */
214 4
    public function write(Query $query): Client
215
    {
216
        // Send commands via loop to router
217 4
        foreach ($query->getQuery() as $command) {
218 4
            $command = trim($command);
219 4
            fwrite($this->_socket, $this->encodeLength($command) . $command);
220
        }
221
222
        // Write zero-terminator
223 4
        fwrite($this->_socket, \chr(0));
224
225 4
        return $this;
226
    }
227
228
    /**
229
     * Read answer from server after query was executed
230
     *
231
     * @param   bool $parse
232
     * @return  array
233
     */
234 4
    public function read(bool $parse = true): array
235
    {
236
        // By default response is empty
237 4
        $response = [];
238
239
        // Read answer from socket in loop
240 4
        while (true) {
241
            // Read the first byte of input which gives us some or all of the length
242
            // of the remaining reply.
243 4
            $byte   = fread($this->_socket, 1);
244 4
            $length = $this->getLength(\ord($byte));
245
246
            // Save only non empty strings
247 4
            if ($length > 0) {
248
                // Save output line to response array
249 4
                $response[] = stream_get_contents($this->_socket, $length);
250
            } else {
251
                // Read next line
252 4
                stream_get_contents($this->_socket, $length);
253
            }
254
255
            // If we get a !done line in response, change state of $isDone variable
256 4
            $isDone = ('!done' === end($response));
257
258
            // Get status about latest operation
259 4
            $status = stream_get_meta_data($this->_socket);
260
261
            // If we do not have unread bytes from socket or <-same and if done, then exit from loop
262 4
            if ((!$status['unread_bytes']) || (!$status['unread_bytes'] && $isDone)) {
263 4
                break;
264
            }
265
        }
266
267
        // Parse results and return
268 4
        return $parse ? $this->parseResponse($response) : $response;
269
    }
270
271
    /**
272
     * Alias for ->write() method
273
     *
274
     * @param   \RouterOS\Query $query
275
     * @return  \RouterOS\Client
276
     * @throws  \RouterOS\Exceptions\ClientException
277
     * @throws  \RouterOS\Exceptions\QueryException
278
     */
279
    public function w(Query $query): Client
280
    {
281
        return $this->write($query);
282
    }
283
284
    /**
285
     * Alias for ->read() method
286
     *
287
     * @param   bool $parse
288
     * @return  array
289
     * @since   0.7
290
     */
291
    public function r(bool $parse = true): array
292
    {
293
        return $this->read($parse);
294
    }
295
296
    /**
297
     * Alias for ->write()->read() combination of methods
298
     *
299
     * @param   \RouterOS\Query $query
300
     * @param   bool            $parse
301
     * @return  array
302
     * @throws  \RouterOS\Exceptions\ClientException
303
     * @throws  \RouterOS\Exceptions\QueryException
304
     * @since   0.6
305
     */
306
    public function wr(Query $query, bool $parse = true): array
307
    {
308
        return $this->write($query)->read($parse);
309
    }
310
311
    /**
312
     * Parse response from Router OS
313
     *
314
     * @param   array $response Response data
315
     * @return  array Array with parsed data
316
     */
317 1
    private function parseResponse(array $response): array
318
    {
319 1
        $result = [];
320 1
        $i      = -1;
321 1
        $lines  = \count($response);
322 1
        foreach ($response as $key => $value) {
323 1
            switch ($value) {
324 1
                case '!re':
325
                    $i++;
326
                    break;
327 1
                case '!fatal':
328
                    $result = $response;
329
                    break 2;
330 1
                case '!trap':
331 1
                case '!done':
332
                    // Check for =ret=, .tag and any other following messages
333 1
                    for ($j = $key + 1; $j <= $lines; $j++) {
334
                        // If we have lines after current one
335 1
                        if (isset($response[$j])) {
336 1
                            $this->pregResponse($response[$j], $matches);
337 1
                            if (!empty($matches)) {
338 1
                                $result['after'][$matches[1][0]] = $matches[2][0];
339
                            }
340
                        }
341
                    }
342 1
                    break 2;
343
                default:
344
                    $this->pregResponse($value, $matches);
345
                    if (!empty($matches)) {
346
                        $result[$i][$matches[1][0]] = $matches[2][0];
347
                    }
348
                    break;
349
            }
350
        }
351 1
        return $result;
352
    }
353
354
    /**
355
     * Parse result from RouterOS by regular expression
356
     *
357
     * @param   string $value
358
     * @param   array  $matches
359
     */
360 1
    private function pregResponse(string $value, &$matches)
361
    {
362 1
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
363 1
    }
364
365
    /**
366
     * Authorization logic
367
     *
368
     * @return  bool
369
     * @throws  \RouterOS\Exceptions\ClientException
370
     * @throws  \RouterOS\Exceptions\ConfigException
371
     * @throws  \RouterOS\Exceptions\QueryException
372
     */
373 4
    private function login(): bool
374
    {
375
        // If legacy login scheme is enabled
376 4
        if ($this->config('legacy')) {
377
            // For the first we need get hash with salt
378 1
            $query    = new Query('/login');
379 1
            $response = $this->write($query)->read();
380
381
            // Now need use this hash for authorization
382 1
            $query = (new Query('/login'))
383 1
                ->add('=name=' . $this->config('user'))
384 1
                ->add('=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret'])));
385
        } else {
386
            // Just login with our credentials
387 3
            $query = (new Query('/login'))
388 3
                ->add('=name=' . $this->config('user'))
389 3
                ->add('=password=' . $this->config('pass'));
390
        }
391
392
        // Execute query and get response
393 4
        $response = $this->write($query)->read(false);
394
395
        // Return true if we have only one line from server and this line is !done
396 4
        return isset($response[0]) && $response[0] === '!done';
397
    }
398
399
    /**
400
     * Connect to socket server
401
     *
402
     * @return  bool
403
     * @throws  \RouterOS\Exceptions\ClientException
404
     * @throws  \RouterOS\Exceptions\ConfigException
405
     * @throws  \RouterOS\Exceptions\QueryException
406
     */
407 5
    private function connect(): bool
408
    {
409
        // By default we not connected
410 5
        $connected = false;
411
412
        // Few attempts in loop
413 5
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
414
415
            // Initiate socket session
416 5
            $this->openSocket();
417
418
            // If socket is active
419 4
            if ($this->getSocket()) {
420
421
                // If we logged in then exit from loop
422 4
                if (true === $this->login()) {
423 4
                    $connected = true;
424 4
                    break;
425
                }
426
427
                // Else close socket and start from begin
428
                $this->closeSocket();
429
            }
430
431
            // Sleep some time between tries
432
            sleep($this->config('delay'));
433
        }
434
435
        // Return status of connection
436 4
        return $connected;
437
    }
438
439
    /**
440
     * Save socket resource to static variable
441
     *
442
     * @param   resource $socket
443
     */
444 4
    private function setSocket($socket)
445
    {
446 4
        $this->_socket = $socket;
447 4
    }
448
449
    /**
450
     * Return socket resource if is exist
451
     *
452
     * @return  resource
453
     */
454 4
    public function getSocket()
455
    {
456 4
        return $this->_socket;
457
    }
458
459
    /**
460
     * Initiate socket session
461
     *
462
     * @throws  \RouterOS\Exceptions\ClientException
463
     * @throws  \RouterOS\Exceptions\ConfigException
464
     */
465 5
    private function openSocket()
466
    {
467
        // Default: Context for ssl
468 5
        $context = stream_context_create([
469 5
            'ssl' => [
470
                'ciphers'          => 'ADH:ALL',
471
                'verify_peer'      => false,
472
                'verify_peer_name' => false
473
            ]
474
        ]);
475
476
        // Default: Proto tcp:// but for ssl we need ssl://
477 5
        $proto = $this->config('ssl') ? 'ssl://' : '';
478
479
        // Initiate socket client
480 5
        $socket = @stream_socket_client(
481 5
            $proto . $this->config('host') . ':' . $this->config('port'),
482 5
            $this->_socket_err_num,
483 5
            $this->_socket_err_str,
484 5
            $this->config('timeout'),
485 5
            STREAM_CLIENT_CONNECT,
486 5
            $context
487
        );
488
489
        // Throw error is socket is not initiated
490 5
        if (!$socket) {
491 1
            throw new ClientException('Unable to establish socket session, ' . $this->_socket_err_str);
492
        }
493
494
        // Save socket to static variable
495 4
        return $this->setSocket($socket);
496
    }
497
498
    /**
499
     * Close socket session
500
     *
501
     * @return bool
502
     */
503
    private function closeSocket(): bool
504
    {
505
        return fclose($this->_socket);
506
    }
507
}
508