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, ShortsTrait; |
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
|
|
|
* |
40
|
|
|
* @throws \RouterOS\Exceptions\ClientException |
41
|
|
|
* @throws \RouterOS\Exceptions\ConfigException |
42
|
|
|
* @throws \RouterOS\Exceptions\QueryException |
43
|
|
|
*/ |
44
|
17 |
|
public function __construct($config) |
45
|
|
|
{ |
46
|
|
|
// If array then need create object |
47
|
17 |
|
if (\is_array($config)) { |
48
|
14 |
|
$config = new Config($config); |
49
|
|
|
} |
50
|
|
|
|
51
|
|
|
// Check for important keys |
52
|
17 |
|
if (true !== $key = ArrayHelper::checkIfKeysNotExist(['host', 'user', 'pass'], $config->getParameters())) { |
53
|
1 |
|
throw new ConfigException("One or few parameters '$key' of Config is not set or empty"); |
54
|
|
|
} |
55
|
|
|
|
56
|
|
|
// Save config if everything is okay |
57
|
16 |
|
$this->_config = $config; |
58
|
|
|
|
59
|
|
|
// Throw error if cannot to connect |
60
|
16 |
|
if (false === $this->connect()) { |
61
|
|
|
throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port')); |
62
|
|
|
} |
63
|
13 |
|
} |
64
|
|
|
|
65
|
|
|
/** |
66
|
|
|
* Get some parameter from config |
67
|
|
|
* |
68
|
|
|
* @param string $parameter Name of required parameter |
69
|
|
|
* |
70
|
|
|
* @return mixed |
71
|
|
|
* @throws \RouterOS\Exceptions\ConfigException |
72
|
|
|
*/ |
73
|
16 |
|
private function config(string $parameter) |
74
|
|
|
{ |
75
|
16 |
|
return $this->_config->get($parameter); |
76
|
|
|
} |
77
|
|
|
|
78
|
|
|
/** |
79
|
|
|
* Send write query to RouterOS |
80
|
|
|
* |
81
|
|
|
* @param string|array|\RouterOS\Query $query |
82
|
|
|
* |
83
|
|
|
* @return \RouterOS\Client |
84
|
|
|
* @throws \RouterOS\Exceptions\QueryException |
85
|
|
|
* @deprecated |
86
|
|
|
* @codeCoverageIgnore |
87
|
|
|
*/ |
88
|
|
|
public function write($query): Client |
89
|
|
|
{ |
90
|
|
|
if (\is_string($query)) { |
91
|
|
|
$query = new Query($query); |
92
|
|
|
} elseif (\is_array($query)) { |
93
|
|
|
$endpoint = array_shift($query); |
94
|
|
|
$query = new Query($endpoint, $query); |
95
|
|
|
} |
96
|
|
|
|
97
|
|
|
if (!$query instanceof Query) { |
98
|
|
|
throw new QueryException('Parameters cannot be processed'); |
99
|
|
|
} |
100
|
|
|
|
101
|
|
|
// Submit query to RouterOS |
102
|
|
|
return $this->writeRAW($query); |
103
|
|
|
} |
104
|
|
|
|
105
|
|
|
/** |
106
|
|
|
* Send write query to RouterOS (modern version of write) |
107
|
|
|
* |
108
|
|
|
* @param string|Query $endpoint Path of API query or Query object |
109
|
|
|
* @param array|null $where List of where filters |
110
|
|
|
* @param string|null $operations Some operations which need make on response |
111
|
|
|
* @param string|null $tag Mark query with tag |
112
|
|
|
* |
113
|
|
|
* @return \RouterOS\Client |
114
|
|
|
* @throws \RouterOS\Exceptions\QueryException |
115
|
|
|
* @throws \RouterOS\Exceptions\ClientException |
116
|
|
|
* @since 1.0.0 |
117
|
|
|
*/ |
118
|
14 |
|
public function query($endpoint, array $where = null, string $operations = null, string $tag = null): Client |
119
|
|
|
{ |
120
|
|
|
// If endpoint is string then build Query object |
121
|
14 |
|
$query = ($endpoint instanceof Query) |
122
|
14 |
|
? $endpoint |
123
|
14 |
|
: new Query($endpoint); |
124
|
|
|
|
125
|
|
|
// Parse where array |
126
|
14 |
|
if (!empty($where)) { |
127
|
|
|
|
128
|
|
|
// If array is multidimensional, then parse each line |
129
|
3 |
|
if (is_array($where[0])) { |
130
|
2 |
|
foreach ($where as $item) { |
131
|
|
|
|
132
|
|
|
// Null by default |
133
|
2 |
|
$key = null; |
134
|
2 |
|
$operator = null; |
135
|
2 |
|
$value = null; |
136
|
|
|
|
137
|
2 |
View Code Duplication |
switch (\count($item)) { |
|
|
|
|
138
|
2 |
|
case 1: |
139
|
1 |
|
list($key) = $item; |
140
|
1 |
|
break; |
141
|
2 |
|
case 2: |
142
|
1 |
|
list($key, $operator) = $item; |
143
|
1 |
|
break; |
144
|
2 |
|
case 3: |
145
|
1 |
|
list($key, $operator, $value) = $item; |
146
|
1 |
|
break; |
147
|
|
|
default: |
148
|
1 |
|
throw new ClientException('From 1 to 3 parameters of "where" condition is allowed'); |
149
|
|
|
} |
150
|
1 |
|
$query->where($key, $operator, $value); |
151
|
|
|
} |
152
|
|
|
} else { |
153
|
|
|
// Null by default |
154
|
2 |
|
$key = null; |
155
|
2 |
|
$operator = null; |
156
|
2 |
|
$value = null; |
157
|
|
|
|
158
|
2 |
View Code Duplication |
switch (\count($where)) { |
|
|
|
|
159
|
2 |
|
case 1: |
160
|
1 |
|
list($key) = $where; |
161
|
1 |
|
break; |
162
|
2 |
|
case 2: |
163
|
1 |
|
list($key, $operator) = $where; |
164
|
1 |
|
break; |
165
|
2 |
|
case 3: |
166
|
1 |
|
list($key, $operator, $value) = $where; |
167
|
1 |
|
break; |
168
|
|
|
default: |
169
|
1 |
|
throw new ClientException('From 1 to 3 parameters of "where" condition is allowed'); |
170
|
|
|
} |
171
|
|
|
|
172
|
1 |
|
$query->where($key, $operator, $value); |
173
|
|
|
} |
174
|
|
|
|
175
|
|
|
} |
176
|
|
|
|
177
|
|
|
// Append operations if set |
178
|
14 |
|
if (!empty($operations)) { |
179
|
1 |
|
$query->operations($operations); |
180
|
|
|
} |
181
|
|
|
|
182
|
|
|
// Append tag if set |
183
|
14 |
|
if (!empty($tag)) { |
184
|
1 |
|
$query->tag($tag); |
185
|
|
|
} |
186
|
|
|
|
187
|
|
|
// Submit query to RouterOS |
188
|
14 |
|
return $this->writeRAW($query); |
189
|
|
|
} |
190
|
|
|
|
191
|
|
|
/** |
192
|
|
|
* Send write query object to RouterOS |
193
|
|
|
* |
194
|
|
|
* @param \RouterOS\Query $query |
195
|
|
|
* |
196
|
|
|
* @return \RouterOS\Client |
197
|
|
|
* @throws \RouterOS\Exceptions\QueryException |
198
|
|
|
* @since 1.0.0 |
199
|
|
|
*/ |
200
|
14 |
|
private function writeRAW(Query $query): Client |
201
|
|
|
{ |
202
|
|
|
// Send commands via loop to router |
203
|
14 |
|
foreach ($query->getQuery() as $command) { |
204
|
14 |
|
$this->_connector->writeWord(trim($command)); |
205
|
|
|
} |
206
|
|
|
|
207
|
|
|
// Write zero-terminator (empty string) |
208
|
14 |
|
$this->_connector->writeWord(''); |
209
|
|
|
|
210
|
14 |
|
return $this; |
211
|
|
|
} |
212
|
|
|
|
213
|
|
|
/** |
214
|
|
|
* Read RAW response from RouterOS |
215
|
|
|
* |
216
|
|
|
* @return array |
217
|
|
|
* @since 1.0.0 |
218
|
|
|
*/ |
219
|
14 |
|
private function readRAW(): array |
220
|
|
|
{ |
221
|
|
|
// By default response is empty |
222
|
14 |
|
$response = []; |
223
|
|
|
// We have to wait a !done or !fatal |
224
|
14 |
|
$lastReply = false; |
225
|
|
|
|
226
|
|
|
// Read answer from socket in loop |
227
|
14 |
|
while (true) { |
228
|
14 |
|
$word = $this->_connector->readWord(); |
229
|
|
|
|
230
|
14 |
|
if ('' === $word) { |
231
|
14 |
|
if ($lastReply) { |
232
|
|
|
// We received a !done or !fatal message in a precedent loop |
233
|
|
|
// response is complete |
234
|
14 |
|
break; |
235
|
|
|
} |
236
|
|
|
// We did not receive the !done or !fatal message |
237
|
|
|
// This 0 length message is the end of a reply !re or !trap |
238
|
|
|
// We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message |
239
|
6 |
|
continue; |
240
|
|
|
} |
241
|
|
|
|
242
|
|
|
// Save output line to response array |
243
|
14 |
|
$response[] = $word; |
244
|
|
|
|
245
|
|
|
// If we get a !done or !fatal line in response, we are now ready to finish the read |
246
|
|
|
// but we need to wait a 0 length message, switch the flag |
247
|
14 |
|
if ('!done' === $word || '!fatal' === $word) { |
248
|
14 |
|
$lastReply = true; |
249
|
|
|
} |
250
|
|
|
} |
251
|
|
|
|
252
|
|
|
// Parse results and return |
253
|
14 |
|
return $response; |
254
|
|
|
} |
255
|
|
|
|
256
|
|
|
/** |
257
|
|
|
* Read answer from server after query was executed |
258
|
|
|
* |
259
|
|
|
* A Mikrotik reply is formed of blocks |
260
|
|
|
* Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal') |
261
|
|
|
* Each block end with an zero byte (empty line) |
262
|
|
|
* Reply ends with a complete !done or !fatal block (ended with 'empty line') |
263
|
|
|
* A !fatal block precedes TCP connexion close |
264
|
|
|
* |
265
|
|
|
* @param bool $parse |
266
|
|
|
* |
267
|
|
|
* @return mixed |
268
|
|
|
*/ |
269
|
14 |
|
public function read(bool $parse = true) |
270
|
|
|
{ |
271
|
|
|
// Read RAW response |
272
|
14 |
|
$response = $this->readRAW(); |
273
|
|
|
|
274
|
|
|
// Parse results and return |
275
|
14 |
|
return $parse ? $this->rosario($response) : $response; |
276
|
|
|
} |
277
|
|
|
|
278
|
|
|
/** |
279
|
|
|
* Read using Iterators to improve performance on large dataset |
280
|
|
|
* |
281
|
|
|
* @return \RouterOS\ResponseIterator |
282
|
|
|
* @since 1.0.0 |
283
|
|
|
*/ |
284
|
4 |
|
public function readAsIterator(): ResponseIterator |
285
|
|
|
{ |
286
|
4 |
|
return new ResponseIterator($this); |
287
|
|
|
} |
288
|
|
|
|
289
|
|
|
/** |
290
|
|
|
* This method was created by memory save reasons, it convert response |
291
|
|
|
* from RouterOS to readable array in safe way. |
292
|
|
|
* |
293
|
|
|
* @param array $raw Array RAW response from server |
294
|
|
|
* |
295
|
|
|
* @return mixed |
296
|
|
|
* |
297
|
|
|
* Based on RouterOSResponseArray solution by @arily |
298
|
|
|
* |
299
|
|
|
* @link https://github.com/arily/RouterOSResponseArray |
300
|
|
|
* @since 1.0.0 |
301
|
|
|
*/ |
302
|
3 |
|
private function rosario(array $raw): array |
303
|
|
|
{ |
304
|
|
|
// This RAW should't be an error |
305
|
3 |
|
$positions = array_keys($raw, '!re'); |
306
|
3 |
|
$count = count($raw); |
307
|
3 |
|
$result = []; |
308
|
|
|
|
309
|
3 |
|
if (isset($positions[1])) { |
310
|
|
|
|
311
|
1 |
|
foreach ($positions as $key => $position) { |
312
|
|
|
// Get length of future block |
313
|
1 |
|
$length = isset($positions[$key + 1]) |
314
|
1 |
|
? $positions[$key + 1] - $position + 1 |
315
|
1 |
|
: $count - $position; |
316
|
|
|
|
317
|
|
|
// Convert array to simple items |
318
|
1 |
|
$item = []; |
319
|
1 |
|
for ($i = 1; $i < $length; $i++) { |
320
|
1 |
|
$item[] = array_shift($raw); |
321
|
|
|
} |
322
|
|
|
|
323
|
|
|
// Save as result |
324
|
1 |
|
$result[] = $this->parseResponse($item)[0]; |
325
|
|
|
} |
326
|
|
|
|
327
|
|
|
} else { |
328
|
3 |
|
$result = $this->parseResponse($raw); |
329
|
|
|
} |
330
|
|
|
|
331
|
3 |
|
return $result; |
332
|
|
|
} |
333
|
|
|
|
334
|
|
|
/** |
335
|
|
|
* Parse response from Router OS |
336
|
|
|
* |
337
|
|
|
* @param array $response Response data |
338
|
|
|
* |
339
|
|
|
* @return array Array with parsed data |
340
|
|
|
*/ |
341
|
4 |
|
public function parseResponse(array $response): array |
342
|
|
|
{ |
343
|
4 |
|
$result = []; |
344
|
4 |
|
$i = -1; |
345
|
4 |
|
$lines = \count($response); |
346
|
4 |
|
foreach ($response as $key => $value) { |
347
|
4 |
|
switch ($value) { |
348
|
4 |
|
case '!re': |
349
|
2 |
|
$i++; |
350
|
2 |
|
break; |
351
|
4 |
|
case '!fatal': |
352
|
1 |
|
$result = $response; |
353
|
1 |
|
break 2; |
354
|
3 |
|
case '!trap': |
355
|
3 |
|
case '!done': |
356
|
|
|
// Check for =ret=, .tag and any other following messages |
357
|
3 |
|
for ($j = $key + 1; $j <= $lines; $j++) { |
358
|
|
|
// If we have lines after current one |
359
|
3 |
|
if (isset($response[$j])) { |
360
|
2 |
|
$this->pregResponse($response[$j], $matches); |
361
|
2 |
View Code Duplication |
if (isset($matches[1][0], $matches[2][0])) { |
|
|
|
|
362
|
2 |
|
$result['after'][$matches[1][0]] = $matches[2][0]; |
363
|
|
|
} |
364
|
|
|
} |
365
|
|
|
} |
366
|
3 |
|
break 2; |
367
|
|
|
default: |
368
|
2 |
|
$this->pregResponse($value, $matches); |
369
|
2 |
View Code Duplication |
if (isset($matches[1][0], $matches[2][0])) { |
|
|
|
|
370
|
2 |
|
$result[$i][$matches[1][0]] = $matches[2][0]; |
371
|
|
|
} |
372
|
2 |
|
break; |
373
|
|
|
} |
374
|
|
|
} |
375
|
4 |
|
return $result; |
376
|
|
|
} |
377
|
|
|
|
378
|
|
|
/** |
379
|
|
|
* Parse result from RouterOS by regular expression |
380
|
|
|
* |
381
|
|
|
* @param string $value |
382
|
|
|
* @param array $matches |
383
|
|
|
*/ |
384
|
3 |
|
private function pregResponse(string $value, &$matches) |
385
|
|
|
{ |
386
|
3 |
|
preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches); |
387
|
3 |
|
} |
388
|
|
|
|
389
|
|
|
/** |
390
|
|
|
* Authorization logic |
391
|
|
|
* |
392
|
|
|
* @param bool $legacyRetry Retry login if we detect legacy version of RouterOS |
393
|
|
|
* |
394
|
|
|
* @return bool |
395
|
|
|
* @throws \RouterOS\Exceptions\ClientException |
396
|
|
|
* @throws \RouterOS\Exceptions\ConfigException |
397
|
|
|
* @throws \RouterOS\Exceptions\QueryException |
398
|
|
|
*/ |
399
|
14 |
|
private function login(bool $legacyRetry = false): bool |
400
|
|
|
{ |
401
|
|
|
// If legacy login scheme is enabled |
402
|
14 |
|
if ($this->config('legacy')) { |
403
|
|
|
// For the first we need get hash with salt |
404
|
1 |
|
$response = $this->query('/login')->read(); |
405
|
|
|
|
406
|
|
|
// Now need use this hash for authorization |
407
|
1 |
|
$query = new Query('/login', [ |
408
|
1 |
|
'=name=' . $this->config('user'), |
409
|
1 |
|
'=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret'])) |
410
|
|
|
]); |
411
|
|
|
} else { |
412
|
|
|
// Just login with our credentials |
413
|
13 |
|
$query = new Query('/login', [ |
414
|
13 |
|
'=name=' . $this->config('user'), |
415
|
13 |
|
'=password=' . $this->config('pass') |
416
|
|
|
]); |
417
|
|
|
|
418
|
|
|
// If we set modern auth scheme but router with legacy firmware then need to retry query, |
419
|
|
|
// but need to prevent endless loop |
420
|
13 |
|
$legacyRetry = true; |
421
|
|
|
} |
422
|
|
|
|
423
|
|
|
// Execute query and get response |
424
|
14 |
|
$response = $this->query($query)->read(false); |
425
|
|
|
|
426
|
|
|
// if: |
427
|
|
|
// - we have more than one response |
428
|
|
|
// - response is '!done' |
429
|
|
|
// => problem with legacy version, swap it and retry |
430
|
|
|
// Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete? |
431
|
14 |
|
if ($legacyRetry && $this->isLegacy($response)) { |
432
|
|
|
$this->_config->set('legacy', true); |
433
|
|
|
return $this->login(); |
434
|
|
|
} |
435
|
|
|
|
436
|
|
|
// If RouterOS answered with invalid credentials then throw error |
437
|
14 |
|
if (!empty($response[0]) && $response[0] === '!trap') { |
438
|
1 |
|
throw new ClientException('Invalid user name or password'); |
439
|
|
|
} |
440
|
|
|
|
441
|
|
|
// Return true if we have only one line from server and this line is !done |
442
|
13 |
|
return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done'); |
443
|
|
|
} |
444
|
|
|
|
445
|
|
|
/** |
446
|
|
|
* Detect by login request if firmware is legacy |
447
|
|
|
* |
448
|
|
|
* @param array $response |
449
|
|
|
* |
450
|
|
|
* @return bool |
451
|
|
|
* @throws ConfigException |
452
|
|
|
*/ |
453
|
13 |
|
private function isLegacy(array &$response): bool |
454
|
|
|
{ |
455
|
13 |
|
return \count($response) > 1 && $response[0] === '!done' && !$this->config('legacy'); |
456
|
|
|
} |
457
|
|
|
|
458
|
|
|
/** |
459
|
|
|
* Connect to socket server |
460
|
|
|
* |
461
|
|
|
* @return bool |
462
|
|
|
* @throws \RouterOS\Exceptions\ClientException |
463
|
|
|
* @throws \RouterOS\Exceptions\ConfigException |
464
|
|
|
* @throws \RouterOS\Exceptions\QueryException |
465
|
|
|
*/ |
466
|
16 |
|
private function connect(): bool |
467
|
|
|
{ |
468
|
|
|
// By default we not connected |
469
|
16 |
|
$connected = false; |
470
|
|
|
|
471
|
|
|
// Few attempts in loop |
472
|
16 |
|
for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) { |
473
|
|
|
|
474
|
|
|
// Initiate socket session |
475
|
16 |
|
$this->openSocket(); |
476
|
|
|
|
477
|
|
|
// If socket is active |
478
|
14 |
|
if (null !== $this->getSocket()) { |
479
|
14 |
|
$this->_connector = new APIConnector(new Streams\ResourceStream($this->getSocket())); |
480
|
|
|
// If we logged in then exit from loop |
481
|
14 |
|
if (true === $this->login()) { |
482
|
13 |
|
$connected = true; |
483
|
13 |
|
break; |
484
|
|
|
} |
485
|
|
|
|
486
|
|
|
// Else close socket and start from begin |
487
|
|
|
$this->closeSocket(); |
488
|
|
|
} |
489
|
|
|
|
490
|
|
|
// Sleep some time between tries |
491
|
|
|
sleep($this->config('delay')); |
492
|
|
|
} |
493
|
|
|
|
494
|
|
|
// Return status of connection |
495
|
13 |
|
return $connected; |
496
|
|
|
} |
497
|
|
|
} |
498
|
|
|
|
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.