Test Failed
Push — master ( 508b2d...2708e3 )
by Mr
03:42
created

Client::closeSocket()   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 0
crap 1
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 4
    public function __construct(ConfigInterface $config)
50
    {
51
        // Check for important keys
52 4
        $this->keysCheck(['host', 'user', 'pass'], $config);
53
54
        // Save config if everything is okay
55 4
        $this->_config = $config;
56
57
        // Throw error if cannot to connect
58 4
        if (false === $this->connect()) {
59 3
            throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port'));
60
        }
61
    }
62
63
    /**
64
     * Check for important keys
65
     *
66
     * @param   array $keys
67
     * @param   ConfigInterface $config
68
     * @throws  ConfigException
69
     */
70 4
    private function keysCheck(array $keys, ConfigInterface $config)
71
    {
72 4
        $parameters = $config->getParameters();
73 4
        foreach ($keys as $key) {
74 4
            if (false === (array_key_exists($key, $parameters) && isset($parameters[$key]))) {
75 4
                throw new ConfigException("Parameter '$key' of Config is not set or empty");
76
            }
77
        }
78 4
    }
79
80
    /**
81
     * Get some parameter from config
82
     *
83
     * @param   string $parameter
84
     * @return  mixed
85
     */
86 4
    private function config(string $parameter)
87
    {
88 4
        return $this->_config->get($parameter);
89
    }
90
91
    /**
92
     * Convert ordinary string to hex string
93
     *
94
     * @param   string $string
95
     * @return  string
96
     */
97 3
    private function encodeLength(string $string): string
98
    {
99
        // Yeah, that's insane, but was more ugly, you need read this post if you interesting a details:
100
        // https://wiki.mikrotik.com/wiki/Manual:API#API_words
101
        switch (true) {
102 3
            case ($string < 0x80):
103 3
                $string = \chr($string);
104 3
                break;
105
            case ($string < 0x4000):
106
                $string |= 0x8000;
107
                $string = \chr(($string >> 8) & 0xFF) . \chr($string & 0xFF);
108
                break;
109
            case ($string < 0x200000):
110
                $string |= 0xC00000;
111
                $string = \chr(($string >> 16) & 0xFF) . \chr(($string >> 8) & 0xFF) . \chr($string & 0xFF);
112
                break;
113 View Code Duplication
            case ($string < 0x10000000):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
114
                $string |= 0xE0000000;
115
                $string = \chr(($string >> 24) & 0xFF) . \chr(($string >> 16) & 0xFF) . \chr(($string >> 8) & 0xFF) . \chr($string & 0xFF);
116
                break;
117 View Code Duplication
            case ($string >= 0x10000000):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
118
                $string = \chr(0xF0) . \chr(($string >> 24) & 0xFF) . \chr(($string >> 16) & 0xFF) . \chr(($string >> 8) & 0xFF) . \chr($string & 0xFF);
119
                break;
120
        }
121
122 3
        return $string;
123
    }
124
125
    /**
126
     * Read length of line
127
     *
128
     * @param   int $byte
129
     * @return  int
130
     */
131 3
    private function getLength(int $byte): int
132
    {
133
        // If the first bit is set then we need to remove the first four bits, shift left 8
134
        // and then read another byte in.
135
        // We repeat this for the second and third bits.
136
        // If the fourth bit is set, we need to remove anything left in the first byte
137
        // and then read in yet another byte.
138 3
        if ($byte & 128) {
139
            if (($byte & 192) === 128) {
140
                $length = (($byte & 63) << 8) + \ord(fread($this->_socket, 1));
141
            } else {
142
                if (($byte & 224) === 192) {
143
                    $length = (($byte & 31) << 8) + \ord(fread($this->_socket, 1));
144
                    $length = ($length << 8) + \ord(fread($this->_socket, 1));
145
                } else {
146
                    if (($byte & 240) === 224) {
147
                        $length = (($byte & 15) << 8) + \ord(fread($this->_socket, 1));
148
                        $length = ($length << 8) + \ord(fread($this->_socket, 1));
149
                        $length = ($length << 8) + \ord(fread($this->_socket, 1));
150
                    } else {
151
                        $length = \ord(fread($this->_socket, 1));
152
                        $length = ($length << 8) + \ord(fread($this->_socket, 1)) * 3;
153
                        $length = ($length << 8) + \ord(fread($this->_socket, 1));
154
                        $length = ($length << 8) + \ord(fread($this->_socket, 1));
155
                    }
156
                }
157
            }
158
        } else {
159 3
            $length = $byte;
160
        }
161 3
        return $length;
162
    }
163
164
    /**
165
     * Send write query to RouterOS (with or without tag)
166
     *
167
     * @param   QueryInterface $query
168
     * @return  ClientInterface
169
     */
170 3
    public function write(QueryInterface $query): ClientInterface
171
    {
172
        // Send commands via loop to router
173 3
        foreach ($query->getQuery() as $command) {
174 3
            $command = trim($command);
175 3
            fwrite($this->_socket, $this->encodeLength(\strlen($command)) . $command);
176
        }
177
178
        // Write zero-terminator
179 3
        fwrite($this->_socket, \chr(0));
180
181 3
        return $this;
182
    }
183
184
    /**
185
     * Read answer from server after query was executed
186
     *
187
     * @param   bool $parse
188
     * @return  array
189
     */
190 3
    public function read(bool $parse = true): array
191
    {
192
        // By default response is empty
193 3
        $response = [];
194
195
        // Read answer from socket in loop
196 3
        while (true) {
197
            // Read the first byte of input which gives us some or all of the length
198
            // of the remaining reply.
199 3
            $byte = \ord(fread($this->_socket, 1));
200
201
            // Read length of line
202 3
            $length = $this->getLength($byte);
203
204
            // Save only non empty strings
205 3
            if ($length > 0) {
206
                // Save output line to response array
207 3
                $response[] = stream_get_contents($this->_socket, $length);
208
            } else {
209
                // Read next line
210 3
                stream_get_contents($this->_socket, $length);
211
            }
212
213
            // If we get a !done line in response, change state of $isDone variable
214 3
            $isDone = ('!done' === end($response));
215
216
            // Get status about latest operation
217 3
            $status = stream_get_meta_data($this->_socket);
218
219
            // If we do not have unread bytes from socket or <-same and if done, then exit from loop
220 3
            if ((!$status['unread_bytes']) || (!$status['unread_bytes'] && $isDone)) {
221 3
                break;
222
            }
223
        }
224
225
        // Parse results and return
226 3
        return $parse ? $this->parseResponse($response) : $response;
227
    }
228
229
    /**
230
     * Parse response from Router OS
231
     *
232
     * @param   array $response Response data
233
     * @return  array Array with parsed data
234
     */
235 1
    private function parseResponse(array $response): array
236
    {
237 1
        $result = [];
238 1
        $i = -1;
239 1
        $lines = \count($response);
240 1
        foreach ($response as $key => $value) {
241
            switch ($value) {
242 1
                case '!re':
243
                    $i++;
244
                    break;
245 1
                case '!fatal':
246 1
                case '!trap':
247 1
                case '!done':
248
                    // Check for =ret=, .tag and any other following messages
249 1
                    for ($j = $key + 1; $j <= $lines; $j++) {
250
                        // If we have lines after current one
251 1
                        if (isset($response[$j])) {
252 1
                            $this->pregResponse($response[$j], $matches);
253 1
                            if (!empty($matches)) {
254 1
                                $result['after'][$matches[1][0]] = $matches[2][0];
255
                            }
256
                        }
257
                    }
258 1
                    break 2;
259
                default:
260
                    $this->pregResponse($value, $matches);
261
                    if (!empty($matches)) {
262
                        $result[$i][$matches[1][0]] = $matches[2][0];
263
                    }
264
                    break;
265
            }
266
        }
267 1
        return $result;
268
    }
269
270 1
    private function pregResponse(string $value, &$matches)
271
    {
272 1
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
273 1
    }
274
275
    /**
276
     * Authorization logic
277
     *
278
     * @return  bool
279
     */
280 3
    private function login(): bool
281
    {
282
        // If legacy login scheme is enabled
283 3
        if ($this->config('legacy')) {
284
            // For the first we need get hash with salt
285 1
            $query = new Query('/login');
286 1
            $response = $this->write($query)->read();
287
288
            // Now need use this hash for authorization
289 1
            $query = (new Query('/login'))
290 1
                ->add('=name=' . $this->config('user'))
291 1
                ->add('=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret'])));
292
        } else {
293
            // Just login with our credentials
294 2
            $query = (new Query('/login'))
295 2
                ->add('=name=' . $this->config('user'))
296 2
                ->add('=password=' . $this->config('pass'));
297
        }
298
299
        // Execute query and get response
300 3
        $response = $this->write($query)->read(false);
301
302
        // Return true if we have only one line from server and this line is !done
303 3
        return isset($response[0]) && $response[0] === '!done';
304
    }
305
306
    /**
307
     * Connect to socket server
308
     *
309
     * @return  bool
310
     * @throws  ClientException
311
     */
312 4
    private function connect(): bool
313
    {
314
        // By default we not connected
315 4
        $connected = false;
316
317
        // Few attempts in loop
318 4
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
319
320
            // Initiate socket session
321 4
            $this->openSocket();
322
323
            // If socket is active
324 3
            if ($this->getSocket()) {
325
326
                // If we logged in then exit from loop
327 3
                if (true === $this->login()) {
328
                    $connected = true;
329
                    break;
330
                }
331
332
                // Else close socket and start from begin
333 3
                $this->closeSocket();
334
            }
335
336
            // Sleep some time between tries
337 3
            sleep($this->config('delay'));
338
        }
339
340
        // Return status of connection
341 3
        return $connected;
342
    }
343
344
    /**
345
     * Save socket resource to static variable
346
     *
347
     * @param   resource $socket
348
     */
349 3
    private function setSocket($socket)
350
    {
351 3
        $this->_socket = $socket;
352 3
    }
353
354
    /**
355
     * Return socket resource if is exist
356
     *
357
     * @return  resource
358
     */
359 3
    public function getSocket()
360
    {
361 3
        return $this->_socket;
362
    }
363
364
    /**
365
     * Initiate socket session
366
     *
367
     * @throws  ClientException
368
     */
369 4
    private function openSocket()
370
    {
371
        // Default: Context for ssl
372 4
        $context = stream_context_create([
373 4
            'ssl' => [
374
                'ciphers' => 'ADH:ALL',
375
                'verify_peer' => false,
376
                'verify_peer_name' => false
377
            ]
378
        ]);
379
380
        // Default: Proto tcp:// but for ssl we need ssl://
381 4
        $proto = $this->config('ssl') ? 'ssl://' : '';
382
383
        // Initiate socket client
384 4
        $socket = @stream_socket_client(
385 4
            $proto . $this->config('host') . ':' . $this->config('port'),
386 4
            $this->_socket_err_num,
387 4
            $this->_socket_err_str,
388 4
            $this->config('timeout'),
389 4
            STREAM_CLIENT_CONNECT,
390 4
            $context
391
        );
392
393
        // Throw error is socket is not initiated
394 4
        if (!$socket) {
395 1
            throw new ClientException('Unable to establish socket session, ' . $this->_socket_err_str);
396
        }
397
398
        // Save socket to static variable
399 3
        return $this->setSocket($socket);
400
    }
401
402
    /**
403
     * Close socket session
404
     *
405
     * @return bool
406
     */
407 3
    private function closeSocket(): bool
408
    {
409 3
        return fclose($this->_socket);
410
    }
411
}
412