Completed
Pull Request — master (#12)
by jmross
10:20
created

Client::pregResponse()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 3
cts 3
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
     * Configuration of connection
22
     *
23
     * @var \RouterOS\Config
24
     */
25
    private $_config;
26
27
    /**
28
     * API communication object
29
     *
30
     * @var \RouterOS\APIConnector
31
     */
32
33
    private $_connector;
34
35
    /**
36
     * Client constructor.
37
     *
38
     * @param   array|\RouterOS\Config $config
39
     * @throws  \RouterOS\Exceptions\ClientException
40
     * @throws  \RouterOS\Exceptions\ConfigException
41
     * @throws  \RouterOS\Exceptions\QueryException
42
     */
43 14
    public function __construct($config)
44
    {
45
        // If array then need create object
46 14
        if (\is_array($config)) {
47 3
            $config = new Config($config);
48
        }
49
50
        // Check for important keys
51 14
        if (true !== $key = ArrayHelper::checkIfKeysNotExist(['host', 'user', 'pass'], $config->getParameters())) {
52 1
            throw new ConfigException("One or few parameters '$key' of Config is not set or empty");
53
        }
54
55
        // Save config if everything is okay
56 13
        $this->setConfig($config);
57
58
        // Throw error if cannot to connect
59 13
        if (false === $this->connect()) {
60 1
            throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port'));
61
        }
62 11
    }
63
64
    /**
65
     * Get some parameter from config
66
     *
67
     * @param   string $parameter Name of required parameter
68
     * @return  mixed
69
     * @throws  \RouterOS\Exceptions\ConfigException
70
     */
71 13
    private function config(string $parameter)
72
    {
73 13
        return $this->_config->get($parameter);
74
    }
75
76
    /**
77
     * Return socket resource if is exist
78
     *
79
     * @return  \RouterOS\Config
80
     * @since   0.6
81
     */
82 1
    public function getConfig(): Config
83
    {
84 1
        return $this->_config;
85
    }
86
87
    /**
88
     * Set configuration of client
89
     *
90
     * @param   \RouterOS\Config $config
91
     * @since   0.7
92
     */
93 13
    public function setConfig(Config $config)
94
    {
95 13
        $this->_config = $config;
96 13
    }
97
98
    /**
99
     * Send write query to RouterOS (with or without tag)
100
     *
101
     * @param   string|array|\RouterOS\Query $query
102
     * @return  \RouterOS\Client
103
     * @throws  \RouterOS\Exceptions\QueryException
104
     */
105 12
    public function write($query): Client
106
    {
107 12
        if (\is_string($query)) {
108 2
            $query = new Query($query);
109 12
        } elseif (\is_array($query)) {
110 1
            $endpoint = array_shift($query);
111 1
            $query    = new Query($endpoint, $query);
112
        }
113
114 12
        if (!$query instanceof Query) {
115 1
            throw new QueryException('Parameters cannot be processed');
116
        }
117
118
        // Send commands via loop to router
119 12
        foreach ($query->getQuery() as $command) {
120 12
            $this->_connector->writeWord(trim($command));
121
        }
122
123
        // Write zero-terminator (empty string)
124 12
        $this->_connector->writeWord('');
125
126 12
        return $this;
127
    }
128
129
    /**
130
     * Read answer from server after query was executed
131
     *
132
     * A Mikrotik reply is formed of blocks
133
     * Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal')
134
     * Each block end with an zero byte (empty line)
135
     * Reply ends with a complete !done or !fatal block (ended with 'empty line')
136
     * A !fatal block precedes TCP connexion close
137
     *
138
     * @param   bool $parse
139
     * @return  array
140
     */
141 12
    public function read(bool $parse = true): array
142
    {
143
        // By default response is empty
144 12
        $response = [];
145
        // We have to wait a !done or !fatal 
146 12
        $lastReply = false;
147
148
        // Read answer from socket in loop
149 12
        while (true) {
150 12
            $word = $this->_connector->readWord();
151
152 12
            if ('' === $word) {
153 12
                if ($lastReply) {
154
                    // We received a !done or !fatal message in a precedent loop
155
                    // response is complete
156 12
                    break;
157
                }
158
                // We did not receive the !done or !fatal message
159
                // This 0 length message is the end of a reply !re or !trap
160
                // We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message
161 4
                continue;
162
            }
163
164
            // Save output line to response array
165 12
            $response[] = $word;
166
167
            // If we get a !done or !fatal line in response, we are now ready to finish the read
168
            // but we need to wait a 0 length message, switch the flag
169 12
            if ('!done' === $word || '!fatal' === $word) {
170 12
                $lastReply = true;
171
            }
172
        }
173
174
        // Parse results and return
175 12
        return $parse ? $this->parseResponse($response) : $response;
176
    }
177
178
    /**
179
     * Alias for ->write() method
180
     *
181
     * @param   string|array|\RouterOS\Query $query
182
     * @return  \RouterOS\Client
183
     * @throws  \RouterOS\Exceptions\QueryException
184
     */
185 1
    public function w($query): Client
186
    {
187 1
        return $this->write($query);
188
    }
189
190
    /**
191
     * Alias for ->read() method
192
     *
193
     * @param   bool $parse
194
     * @return  array
195
     * @since   0.7
196
     */
197 1
    public function r(bool $parse = true): array
198
    {
199 1
        return $this->read($parse);
200
    }
201
202
    /**
203
     * Alias for ->write()->read() combination of methods
204
     *
205
     * @param   string|array|\RouterOS\Query $query
206
     * @param   bool                         $parse
207
     * @return  array
208
     * @throws  \RouterOS\Exceptions\ClientException
209
     * @throws  \RouterOS\Exceptions\QueryException
210
     * @since   0.6
211
     */
212 4
    public function wr($query, bool $parse = true): array
213
    {
214 4
        return $this->write($query)->read($parse);
215
    }
216
217
    /**
218
     * Parse response from Router OS
219
     *
220
     * @param   array $response Response data
221
     * @return  array Array with parsed data
222
     */
223 4
    private function parseResponse(array $response): array
224
    {
225 4
        $result = [];
226 4
        $i      = -1;
227 4
        $lines  = \count($response);
228 4
        foreach ($response as $key => $value) {
229
            switch ($value) {
230 4
                case '!re':
231 1
                    $i++;
232 1
                    break;
233 4
                case '!fatal':
234 1
                    $result = $response;
235 1
                    break 2;
236 3
                case '!trap':
237 3
                case '!done':
238
                    // Check for =ret=, .tag and any other following messages
239 3
                    for ($j = $key + 1; $j <= $lines; $j++) {
240
                        // If we have lines after current one
241 3
                        if (isset($response[$j])) {
242 2
                            $this->pregResponse($response[$j], $matches);
243 2 View Code Duplication
                            if (isset($matches[1][0]) && isset($matches[2][0])) {
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...
244 2
                                $result['after'][$matches[1][0]] = $matches[2][0];
245
                            }
246
                        }
247
                    }
248 3
                    break 2;
249
                default:
250 1
                    $this->pregResponse($value, $matches);
251 1 View Code Duplication
                    if (isset($matches[1][0]) && isset($matches[2][0])) {
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...
252 1
                        $result[$i][$matches[1][0]] = $matches[2][0];
253
                    }
254 1
                    break;
255
            }
256
        }
257 4
        return $result;
258
    }
259
260
    /**
261
     * Parse result from RouterOS by regular expression
262
     *
263
     * @param   string $value
264
     * @param   array  $matches
265
     */
266 3
    private function pregResponse(string $value, &$matches)
267
    {
268 3
        preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches);
269 3
    }
270
271
    /**
272
     * Authorization logic
273
     *
274
     * @param   bool $legacyRetry Retry login if we detect legacy version of RouterOS
275
     * @return  bool
276
     * @throws  \RouterOS\Exceptions\ClientException
277
     * @throws  \RouterOS\Exceptions\ConfigException
278
     * @throws  \RouterOS\Exceptions\QueryException
279
     */
280 12
    private function login(bool $legacyRetry = false): bool
281
    {
282
        // If legacy login scheme is enabled
283 12
        if ($this->config('legacy')) {
284
            // For the first we need get hash with salt
285 2
            $query    = new Query('/login');
286 2
            $response = $this->write($query)->read();
287
288
            // Now need use this hash for authorization
289 2
            $query = (new Query('/login'))
290 2
                ->add('=name=' . $this->config('user'))
291 2
                ->add('=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret'])));
292
        } else {
293
            // Just login with our credentials
294 11
            $query = (new Query('/login'))
295 11
                ->add('=name=' . $this->config('user'))
296 11
                ->add('=password=' . $this->config('pass'));
297
298
            // If we set modern auth scheme but router with legacy firmware then need to retry query,
299
            // but need to prevent endless loop
300 11
            $legacyRetry = true;
301
        }
302
303
        // Execute query and get response
304 12
        $response = $this->write($query)->read(false);
305
306
        // if:
307
        //  - we have more than one response
308
        //  - response is '!done'
309
        // => problem with legacy version, swap it and retry
310
        // Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete?
311 12
        if ($legacyRetry && $this->isLegacy($response)) {
312 1
            $this->_config->set('legacy', true);
313 1
            return $this->login();
314
        }
315
316
        // Return true if we have only one line from server and this line is !done
317 12
        return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done');
318
    }
319
320
    /**
321
     * Detect by login request if firmware is legacy
322
     *
323
     * @param   array $response
324
     * @return  bool
325
     * @throws  ConfigException
326
     */
327 11
    private function isLegacy(array &$response): bool
328
    {
329 11
        return \count($response) > 1 && $response[0] === '!done' && !$this->config('legacy');
330
    }
331
332
    /**
333
     * Connect to socket server
334
     *
335
     * @return  bool
336
     * @throws  \RouterOS\Exceptions\ClientException
337
     * @throws  \RouterOS\Exceptions\ConfigException
338
     * @throws  \RouterOS\Exceptions\QueryException
339
     */
340 13
    private function connect(): bool
341
    {
342
        // By default we not connected
343 13
        $connected = false;
344
345
        // Few attempts in loop
346 13
        for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) {
347
348
            // Initiate socket session
349 13
            $this->openSocket();
350
351
            // If socket is active
352 12
            if (null !== $this->getSocket()) {
353 12
                $this->_connector = new APIConnector(new Streams\ResourceStream($this->getSocket()));
354
                // If we logged in then exit from loop
355 12
                if (true === $this->login()) {
356 11
                    $connected = true;
357 11
                    break;
358
                }
359
360
                // Else close socket and start from begin
361 1
                $this->closeSocket();
362
            }
363
364
            // Sleep some time between tries
365 1
            sleep($this->config('delay'));
366
        }
367
368
        // Return status of connection
369 12
        return $connected;
370
    }
371
372
}
373