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
|
|
|
use RouterOS\Interfaces\ClientInterface; |
10
|
|
|
|
11
|
|
|
/** |
12
|
|
|
* Class Client for RouterOS management |
13
|
|
|
* |
14
|
|
|
* @package RouterOS |
15
|
|
|
* @since 0.1 |
16
|
|
|
*/ |
17
|
|
|
class Client implements Interfaces\ClientInterface |
18
|
|
|
{ |
19
|
|
|
use SocketTrait, ShortsTrait; |
20
|
|
|
|
21
|
|
|
/** |
22
|
|
|
* Configuration of connection |
23
|
|
|
* |
24
|
|
|
* @var \RouterOS\Config |
25
|
|
|
*/ |
26
|
|
|
private $_config; |
27
|
|
|
|
28
|
|
|
/** |
29
|
|
|
* API communication object |
30
|
|
|
* |
31
|
|
|
* @var \RouterOS\APIConnector |
32
|
|
|
*/ |
33
|
|
|
|
34
|
|
|
private $_connector; |
35
|
|
|
|
36
|
|
|
/** |
37
|
|
|
* Client constructor. |
38
|
|
|
* |
39
|
|
|
* @param array|\RouterOS\Config $config |
40
|
|
|
* @throws \RouterOS\Exceptions\ClientException |
41
|
|
|
* @throws \RouterOS\Exceptions\ConfigException |
42
|
|
|
* @throws \RouterOS\Exceptions\QueryException |
43
|
|
|
*/ |
44
|
14 |
|
public function __construct($config) |
45
|
|
|
{ |
46
|
|
|
// If array then need create object |
47
|
14 |
|
if (\is_array($config)) { |
48
|
3 |
|
$config = new Config($config); |
49
|
|
|
} |
50
|
|
|
|
51
|
|
|
// Check for important keys |
52
|
14 |
|
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
|
13 |
|
$this->setConfig($config); |
58
|
|
|
|
59
|
|
|
// Throw error if cannot to connect |
60
|
13 |
|
if (false === $this->connect()) { |
61
|
1 |
|
throw new ClientException('Unable to connect to ' . $config->get('host') . ':' . $config->get('port')); |
62
|
|
|
} |
63
|
11 |
|
} |
64
|
|
|
|
65
|
|
|
/** |
66
|
|
|
* Get some parameter from config |
67
|
|
|
* |
68
|
|
|
* @param string $parameter Name of required parameter |
69
|
|
|
* @return mixed |
70
|
|
|
* @throws \RouterOS\Exceptions\ConfigException |
71
|
|
|
*/ |
72
|
13 |
|
private function config(string $parameter) |
73
|
|
|
{ |
74
|
13 |
|
return $this->_config->get($parameter); |
75
|
|
|
} |
76
|
|
|
|
77
|
|
|
/** |
78
|
|
|
* Return socket resource if is exist |
79
|
|
|
* |
80
|
|
|
* @return \RouterOS\Config |
81
|
|
|
* @since 0.6 |
82
|
|
|
*/ |
83
|
1 |
|
public function getConfig(): Config |
84
|
|
|
{ |
85
|
1 |
|
return $this->_config; |
86
|
|
|
} |
87
|
|
|
|
88
|
|
|
/** |
89
|
|
|
* Set configuration of client |
90
|
|
|
* |
91
|
|
|
* @param \RouterOS\Config $config |
92
|
|
|
* @since 0.7 |
93
|
|
|
*/ |
94
|
13 |
|
public function setConfig(Config $config) |
95
|
|
|
{ |
96
|
13 |
|
$this->_config = $config; |
97
|
13 |
|
} |
98
|
|
|
|
99
|
|
|
/** |
100
|
|
|
* Send write query to RouterOS (with or without tag) |
101
|
|
|
* |
102
|
|
|
* @param string|array|\RouterOS\Query $query |
103
|
|
|
* @return \RouterOS\Interfaces\ClientInterface |
104
|
|
|
* @throws \RouterOS\Exceptions\QueryException |
105
|
|
|
*/ |
106
|
12 |
|
public function write($query): ClientInterface |
107
|
|
|
{ |
108
|
12 |
|
if (\is_string($query)) { |
109
|
2 |
|
$query = new Query($query); |
110
|
12 |
|
} elseif (\is_array($query)) { |
111
|
1 |
|
$endpoint = array_shift($query); |
112
|
1 |
|
$query = new Query($endpoint, $query); |
113
|
|
|
} |
114
|
|
|
|
115
|
12 |
|
if (!$query instanceof Query) { |
116
|
1 |
|
throw new QueryException('Parameters cannot be processed'); |
117
|
|
|
} |
118
|
|
|
|
119
|
|
|
// Send commands via loop to router |
120
|
12 |
|
foreach ($query->getQuery() as $command) { |
121
|
12 |
|
$this->_connector->writeWord(trim($command)); |
122
|
|
|
} |
123
|
|
|
|
124
|
|
|
// Write zero-terminator (empty string) |
125
|
12 |
|
$this->_connector->writeWord(''); |
126
|
|
|
|
127
|
12 |
|
return $this; |
|
|
|
|
128
|
|
|
} |
129
|
|
|
|
130
|
|
|
/** |
131
|
|
|
* Read answer from server after query was executed |
132
|
|
|
* |
133
|
|
|
* A Mikrotik reply is formed of blocks |
134
|
|
|
* Each block starts with a word, one of ('!re', '!trap', '!done', '!fatal') |
135
|
|
|
* Each block end with an zero byte (empty line) |
136
|
|
|
* Reply ends with a complete !done or !fatal block (ended with 'empty line') |
137
|
|
|
* A !fatal block precedes TCP connexion close |
138
|
|
|
* |
139
|
|
|
* @param bool $parse |
140
|
|
|
* @return mixed |
141
|
|
|
*/ |
142
|
12 |
|
public function read(bool $parse = true) |
143
|
|
|
{ |
144
|
|
|
// By default response is empty |
145
|
12 |
|
$response = []; |
146
|
|
|
// We have to wait a !done or !fatal |
147
|
12 |
|
$lastReply = false; |
148
|
|
|
|
149
|
|
|
// Read answer from socket in loop |
150
|
12 |
|
while (true) { |
151
|
12 |
|
$word = $this->_connector->readWord(); |
152
|
|
|
|
153
|
12 |
|
if ('' === $word) { |
154
|
12 |
|
if ($lastReply) { |
155
|
|
|
// We received a !done or !fatal message in a precedent loop |
156
|
|
|
// response is complete |
157
|
12 |
|
break; |
158
|
|
|
} |
159
|
|
|
// We did not receive the !done or !fatal message |
160
|
|
|
// This 0 length message is the end of a reply !re or !trap |
161
|
|
|
// We have to wait the router to send a !done or !fatal reply followed by optionals values and a 0 length message |
162
|
4 |
|
continue; |
163
|
|
|
} |
164
|
|
|
|
165
|
|
|
// Save output line to response array |
166
|
12 |
|
$response[] = $word; |
167
|
|
|
|
168
|
|
|
// If we get a !done or !fatal line in response, we are now ready to finish the read |
169
|
|
|
// but we need to wait a 0 length message, switch the flag |
170
|
12 |
|
if ('!done' === $word || '!fatal' === $word) { |
171
|
12 |
|
$lastReply = true; |
172
|
|
|
} |
173
|
|
|
} |
174
|
|
|
|
175
|
|
|
// Parse results and return |
176
|
12 |
|
return $parse ? $this->rosario($response) : $response; |
177
|
|
|
} |
178
|
|
|
|
179
|
|
|
/** |
180
|
|
|
* Read using Iterators to improve performance on large dataset |
181
|
|
|
* |
182
|
|
|
* @return \RouterOS\ResponseIterator |
183
|
|
|
*/ |
184
|
|
|
public function readAsIterator(): ResponseIterator |
185
|
|
|
{ |
186
|
|
|
return new ResponseIterator($this); |
187
|
|
|
} |
188
|
|
|
|
189
|
|
|
/** |
190
|
|
|
* This method was created by memory save reasons, it convert response |
191
|
|
|
* from RouterOS to readable array in safe way. |
192
|
|
|
* |
193
|
|
|
* @param array $raw Array RAW response from server |
194
|
|
|
* @return mixed |
195
|
|
|
* |
196
|
|
|
* Based on RouterOSResponseArray solution by @arily |
197
|
|
|
* |
198
|
|
|
* @link https://github.com/arily/RouterOSResponseArray |
199
|
|
|
* @since 0.10 |
200
|
|
|
*/ |
201
|
4 |
|
private function rosario(array $raw): array |
202
|
|
|
{ |
203
|
|
|
// This RAW should't be an error |
204
|
4 |
|
$positions = array_keys($raw, '!re'); |
205
|
4 |
|
$count = count($raw); |
206
|
4 |
|
$result = []; |
207
|
|
|
|
208
|
4 |
|
if (isset($positions[1])) { |
209
|
|
|
|
210
|
|
|
foreach ($positions as $key => $position) { |
211
|
|
|
// Get length of future block |
212
|
|
|
$length = isset($positions[$key + 1]) |
213
|
|
|
? $positions[$key + 1] - $position + 1 |
214
|
|
|
: $count - $position; |
215
|
|
|
|
216
|
|
|
// Convert array to simple items |
217
|
|
|
$item = []; |
218
|
|
|
for ($i = 1; $i < $length; $i++) { |
219
|
|
|
$item[] = array_shift($raw); |
220
|
|
|
} |
221
|
|
|
|
222
|
|
|
// Save as result |
223
|
|
|
$result[] = $this->parseResponse($item)[0]; |
224
|
|
|
} |
225
|
|
|
|
226
|
|
|
} else { |
227
|
4 |
|
$result = $this->parseResponse($raw); |
228
|
|
|
} |
229
|
|
|
|
230
|
4 |
|
return $result; |
231
|
|
|
} |
232
|
|
|
|
233
|
|
|
/** |
234
|
|
|
* Parse response from Router OS |
235
|
|
|
* |
236
|
|
|
* @param array $response Response data |
237
|
|
|
* @return array Array with parsed data |
238
|
|
|
*/ |
239
|
4 |
|
public function parseResponse(array $response): array |
240
|
|
|
{ |
241
|
4 |
|
$result = []; |
242
|
4 |
|
$i = -1; |
243
|
4 |
|
$lines = \count($response); |
244
|
4 |
|
foreach ($response as $key => $value) { |
245
|
|
|
switch ($value) { |
246
|
4 |
|
case '!re': |
247
|
1 |
|
$i++; |
248
|
1 |
|
break; |
249
|
4 |
|
case '!fatal': |
250
|
1 |
|
$result = $response; |
251
|
1 |
|
break 2; |
252
|
3 |
|
case '!trap': |
253
|
3 |
|
case '!done': |
254
|
|
|
// Check for =ret=, .tag and any other following messages |
255
|
3 |
|
for ($j = $key + 1; $j <= $lines; $j++) { |
256
|
|
|
// If we have lines after current one |
257
|
3 |
|
if (isset($response[$j])) { |
258
|
2 |
|
$this->pregResponse($response[$j], $matches); |
259
|
2 |
View Code Duplication |
if (isset($matches[1][0], $matches[2][0])) { |
|
|
|
|
260
|
2 |
|
$result['after'][$matches[1][0]] = $matches[2][0]; |
261
|
|
|
} |
262
|
|
|
} |
263
|
|
|
} |
264
|
3 |
|
break 2; |
265
|
|
|
default: |
266
|
1 |
|
$this->pregResponse($value, $matches); |
267
|
1 |
View Code Duplication |
if (isset($matches[1][0], $matches[2][0])) { |
|
|
|
|
268
|
1 |
|
$result[$i][$matches[1][0]] = $matches[2][0]; |
269
|
|
|
} |
270
|
1 |
|
break; |
271
|
|
|
} |
272
|
|
|
} |
273
|
4 |
|
return $result; |
274
|
|
|
} |
275
|
|
|
|
276
|
|
|
/** |
277
|
|
|
* Parse result from RouterOS by regular expression |
278
|
|
|
* |
279
|
|
|
* @param string $value |
280
|
|
|
* @param array $matches |
281
|
|
|
*/ |
282
|
3 |
|
private function pregResponse(string $value, &$matches) |
283
|
|
|
{ |
284
|
3 |
|
preg_match_all('/^[=|\.](.*)=(.*)/', $value, $matches); |
285
|
3 |
|
} |
286
|
|
|
|
287
|
|
|
/** |
288
|
|
|
* Authorization logic |
289
|
|
|
* |
290
|
|
|
* @param bool $legacyRetry Retry login if we detect legacy version of RouterOS |
291
|
|
|
* @return bool |
292
|
|
|
* @throws \RouterOS\Exceptions\ClientException |
293
|
|
|
* @throws \RouterOS\Exceptions\ConfigException |
294
|
|
|
* @throws \RouterOS\Exceptions\QueryException |
295
|
|
|
*/ |
296
|
12 |
|
private function login(bool $legacyRetry = false): bool |
297
|
|
|
{ |
298
|
|
|
// If legacy login scheme is enabled |
299
|
12 |
|
if ($this->config('legacy')) { |
300
|
|
|
// For the first we need get hash with salt |
301
|
2 |
|
$query = new Query('/login'); |
302
|
2 |
|
$response = $this->write($query)->read(); |
|
|
|
|
303
|
|
|
|
304
|
|
|
// Now need use this hash for authorization |
305
|
2 |
|
$query = (new Query('/login')) |
306
|
2 |
|
->add('=name=' . $this->config('user')) |
307
|
2 |
|
->add('=response=00' . md5(\chr(0) . $this->config('pass') . pack('H*', $response['after']['ret']))); |
308
|
|
|
} else { |
309
|
|
|
// Just login with our credentials |
310
|
11 |
|
$query = (new Query('/login')) |
311
|
11 |
|
->add('=name=' . $this->config('user')) |
312
|
11 |
|
->add('=password=' . $this->config('pass')); |
313
|
|
|
|
314
|
|
|
// If we set modern auth scheme but router with legacy firmware then need to retry query, |
315
|
|
|
// but need to prevent endless loop |
316
|
11 |
|
$legacyRetry = true; |
317
|
|
|
} |
318
|
|
|
|
319
|
|
|
// Execute query and get response |
320
|
12 |
|
$response = $this->write($query)->read(false); |
321
|
|
|
|
322
|
|
|
// if: |
323
|
|
|
// - we have more than one response |
324
|
|
|
// - response is '!done' |
325
|
|
|
// => problem with legacy version, swap it and retry |
326
|
|
|
// Only tested with ROS pre 6.43, will test with post 6.43 => this could make legacy parameter obsolete? |
327
|
12 |
|
if ($legacyRetry && $this->isLegacy($response)) { |
328
|
1 |
|
$this->_config->set('legacy', true); |
329
|
1 |
|
return $this->login(); |
330
|
|
|
} |
331
|
|
|
|
332
|
|
|
// Return true if we have only one line from server and this line is !done |
333
|
12 |
|
return (1 === count($response)) && isset($response[0]) && ($response[0] === '!done'); |
334
|
|
|
} |
335
|
|
|
|
336
|
|
|
/** |
337
|
|
|
* Detect by login request if firmware is legacy |
338
|
|
|
* |
339
|
|
|
* @param array $response |
340
|
|
|
* @return bool |
341
|
|
|
* @throws ConfigException |
342
|
|
|
*/ |
343
|
11 |
|
private function isLegacy(array &$response): bool |
344
|
|
|
{ |
345
|
11 |
|
return \count($response) > 1 && $response[0] === '!done' && !$this->config('legacy'); |
346
|
|
|
} |
347
|
|
|
|
348
|
|
|
/** |
349
|
|
|
* Connect to socket server |
350
|
|
|
* |
351
|
|
|
* @return bool |
352
|
|
|
* @throws \RouterOS\Exceptions\ClientException |
353
|
|
|
* @throws \RouterOS\Exceptions\ConfigException |
354
|
|
|
* @throws \RouterOS\Exceptions\QueryException |
355
|
|
|
*/ |
356
|
13 |
|
private function connect(): bool |
357
|
|
|
{ |
358
|
|
|
// By default we not connected |
359
|
13 |
|
$connected = false; |
360
|
|
|
|
361
|
|
|
// Few attempts in loop |
362
|
13 |
|
for ($attempt = 1; $attempt <= $this->config('attempts'); $attempt++) { |
363
|
|
|
|
364
|
|
|
// Initiate socket session |
365
|
13 |
|
$this->openSocket(); |
366
|
|
|
|
367
|
|
|
// If socket is active |
368
|
12 |
|
if (null !== $this->getSocket()) { |
369
|
12 |
|
$this->_connector = new APIConnector(new Streams\ResourceStream($this->getSocket())); |
370
|
|
|
// If we logged in then exit from loop |
371
|
12 |
|
if (true === $this->login()) { |
372
|
11 |
|
$connected = true; |
373
|
11 |
|
break; |
374
|
|
|
} |
375
|
|
|
|
376
|
|
|
// Else close socket and start from begin |
377
|
1 |
|
$this->closeSocket(); |
378
|
|
|
} |
379
|
|
|
|
380
|
|
|
// Sleep some time between tries |
381
|
1 |
|
sleep($this->config('delay')); |
382
|
|
|
} |
383
|
|
|
|
384
|
|
|
// Return status of connection |
385
|
12 |
|
return $connected; |
386
|
|
|
} |
387
|
|
|
} |
388
|
|
|
|
If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.
Let’s take a look at an example:
Our function
my_function
expects aPost
object, and outputs the author of the post. The base classPost
returns a simple string and outputting a simple string will work just fine. However, the child classBlogPost
which is a sub-type ofPost
instead decided to return anobject
, and is therefore violating the SOLID principles. If aBlogPost
were passed tomy_function
, PHP would not complain, but ultimately fail when executing thestrtoupper
call in its body.