Completed
Push — master ( 2297e5...9e85a5 )
by Mr
03:58
created

Client::encodeLength()   B

Complexity

Conditions 6
Paths 9

Size

Total Lines 37

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 8.9591

Importance

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