Completed
Push — master ( 9e85a5...55cea2 )
by Mr
03:51
created

Client::read()   B

Complexity

Conditions 7
Paths 10

Size

Total Lines 36

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 7

Importance

Changes 0
Metric Value
dl 0
loc 36
ccs 13
cts 13
cp 1
rs 8.4106
c 0
b 0
f 0
cc 7
nc 10
nop 1
crap 7
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
                    $result = $response;
257
                    break 2;
258 1
                case '!trap':
259 1
                case '!done':
260
                    // Check for =ret=, .tag and any other following messages
261 1
                    for ($j = $key + 1; $j <= $lines; $j++) {
262
                        // If we have lines after current one
263 1
                        if (isset($response[$j])) {
264 1
                            $this->pregResponse($response[$j], $matches);
265 1
                            if (!empty($matches)) {
266 1
                                $result['after'][$matches[1][0]] = $matches[2][0];
267
                            }
268
                        }
269
                    }
270 1
                    break 2;
271
                default:
272
                    $this->pregResponse($value, $matches);
273
                    if (!empty($matches)) {
274
                        $result[$i][$matches[1][0]] = $matches[2][0];
275
                    }
276
                    break;
277
            }
278
        }
279 1
        return $result;
280
    }
281
282
    /**
283
     * Parse result from RouterOS by regular expression
284
     *
285
     * @param   string $value
286
     * @param   array $matches
287
     */
288 1
    private function pregResponse(string $value, &$matches)
289
    {
290 1
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
291 1
    }
292
293
    /**
294
     * Authorization logic
295
     *
296
     * @return  bool
297
     * @throws  ClientException
298
     */
299 4
    private function login(): bool
300
    {
301
        // If legacy login scheme is enabled
302 4
        if ($this->config('legacy')) {
303
            // For the first we need get hash with salt
304 1
            $query = new Query('/login');
305 1
            $response = $this->write($query)->read();
306
307
            // Now need use this hash for authorization
308 1
            $query = (new Query('/login'))
309 1
                ->add('=name=' . $this->config('user'))
310 1
                ->add('=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*',
311 1
                            $response['after']['ret'])));
312
        } else {
313
            // Just login with our credentials
314 3
            $query = (new Query('/login'))
315 3
                ->add('=name=' . $this->config('user'))
316 3
                ->add('=password=' . $this->config('pass'));
317
        }
318
319
        // Execute query and get response
320 4
        $response = $this->write($query)->read(false);
321
322
        // Return true if we have only one line from server and this line is !done
323 4
        return isset($response[0]) && $response[0] === '!done';
324
    }
325
326
    /**
327
     * Connect to socket server
328
     *
329
     * @return  bool
330
     * @throws  ClientException
331
     */
332 5
    private function connect(): bool
333
    {
334
        // By default we not connected
335 5
        $connected = false;
336
337
        // Few attempts in loop
338 5
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
339
340
            // Initiate socket session
341 5
            $this->openSocket();
342
343
            // If socket is active
344 4
            if ($this->getSocket()) {
345
346
                // If we logged in then exit from loop
347 4
                if (true === $this->login()) {
348 4
                    $connected = true;
349 4
                    break;
350
                }
351
352
                // Else close socket and start from begin
353
                $this->closeSocket();
354
            }
355
356
            // Sleep some time between tries
357
            sleep($this->config('delay'));
358
        }
359
360
        // Return status of connection
361 4
        return $connected;
362
    }
363
364
    /**
365
     * Save socket resource to static variable
366
     *
367
     * @param   resource $socket
368
     */
369 4
    private function setSocket($socket)
370
    {
371 4
        $this->_socket = $socket;
372 4
    }
373
374
    /**
375
     * Return socket resource if is exist
376
     *
377
     * @return  resource
378
     */
379 4
    public function getSocket()
380
    {
381 4
        return $this->_socket;
382
    }
383
384
    /**
385
     * Initiate socket session
386
     *
387
     * @throws  ClientException
388
     */
389 5
    private function openSocket()
390
    {
391
        // Default: Context for ssl
392 5
        $context = stream_context_create([
393 5
            'ssl' => [
394
                'ciphers' => 'ADH:ALL',
395
                'verify_peer' => false,
396
                'verify_peer_name' => false
397
            ]
398
        ]);
399
400
        // Default: Proto tcp:// but for ssl we need ssl://
401 5
        $proto = $this->config('ssl') ? 'ssl://' : '';
402
403
        // Initiate socket client
404 5
        $socket = @stream_socket_client(
405 5
            $proto . $this->config('host') . ':' . $this->config('port'),
406 5
            $this->_socket_err_num,
407 5
            $this->_socket_err_str,
408 5
            $this->config('timeout'),
409 5
            STREAM_CLIENT_CONNECT,
410 5
            $context
411
        );
412
413
        // Throw error is socket is not initiated
414 5
        if (!$socket) {
415 1
            throw new ClientException('Unable to establish socket session, ' . $this->_socket_err_str);
416
        }
417
418
        // Save socket to static variable
419 4
        return $this->setSocket($socket);
420
    }
421
422
    /**
423
     * Close socket session
424
     *
425
     * @return bool
426
     */
427
    private function closeSocket(): bool
428
    {
429
        return fclose($this->_socket);
430
    }
431
}
432