Completed
Push — develop ( c36f52...a7dd5e )
by Vasil
03:51
created

Util::move()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 23
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 11
Bugs 6 Features 8
Metric Value
c 11
b 6
f 8
dl 0
loc 23
rs 8.7972
cc 4
eloc 17
nc 6
nop 2
1
<?php
2
3
/**
4
 * ~~summary~~
5
 *
6
 * ~~description~~
7
 *
8
 * PHP version 5
9
 *
10
 * @category  Net
11
 * @package   PEAR2_Net_RouterOS
12
 * @author    Vasil Rangelov <[email protected]>
13
 * @copyright 2011 Vasil Rangelov
14
 * @license   http://www.gnu.org/copyleft/lesser.html LGPL License 2.1
15
 * @version   GIT: $Id$
16
 * @link      http://pear2.php.net/PEAR2_Net_RouterOS
17
 */
18
/**
19
 * The namespace declaration.
20
 */
21
namespace PEAR2\Net\RouterOS;
22
23
/**
24
 * Returned from {@link Util::getCurrentTime()}.
25
 */
26
use DateTime;
27
28
/**
29
 * Used at {@link Util::getCurrentTime()} to get the proper time.
30
 */
31
use DateTimeZone;
32
33
/**
34
 * Implemented by this class.
35
 */
36
use Countable;
37
38
/**
39
 * Used to detect streams in various methods of this class.
40
 */
41
use PEAR2\Net\Transmitter\Stream;
42
43
/**
44
 * Used to catch a DateTime exception at {@link Util::getCurrentTime()}.
45
 */
46
use Exception as E;
47
48
/**
49
 * Utility class.
50
 *
51
 * Abstracts away frequently used functionality (particularly CRUD operations)
52
 * in convenient to use methods by wrapping around a connection.
53
 *
54
 * @category Net
55
 * @package  PEAR2_Net_RouterOS
56
 * @author   Vasil Rangelov <[email protected]>
57
 * @license  http://www.gnu.org/copyleft/lesser.html LGPL License 2.1
58
 * @link     http://pear2.php.net/PEAR2_Net_RouterOS
59
 */
60
class Util implements Countable
61
{
62
    /**
63
     * @var Client The connection to wrap around.
64
     */
65
    protected $client;
66
67
    /**
68
     * @var string The current menu. Note that the root menu (only) uses an
69
     *     empty string. This is done to enable commands executed at it without
70
     *     special casing it at all commands. Instead, only
71
     *     {@link static::setMenu()} is special cased.
72
     */
73
    protected $menu = '';
74
75
    /**
76
     * @var array<int,string>|null An array with the numbers of items in
77
     *     the current menu as keys, and the corresponding IDs as values.
78
     *     NULL when the cache needs regenerating.
79
     */
80
    protected $idCache = null;
81
82
    /**
83
     * Creates a new Util instance.
84
     *
85
     * Wraps around a connection to provide convenience methods.
86
     *
87
     * @param Client $client The connection to wrap around.
88
     */
89
    public function __construct(Client $client)
90
    {
91
        $this->client = $client;
92
    }
93
94
    /**
95
     * Gets the current menu.
96
     *
97
     * @return string The absolute path to current menu, using API syntax.
98
     */
99
    public function getMenu()
100
    {
101
        return '' === $this->menu ? '/' : $this->menu;
102
    }
103
104
    /**
105
     * Sets the current menu.
106
     *
107
     * Sets the current menu.
108
     *
109
     * @param string $newMenu The menu to change to. Can be specified with API
110
     *     or CLI syntax and can be either absolute or relative. If relative,
111
     *     it's relative to the current menu, which by default is the root.
112
     *
113
     * @return $this The object itself. If an empty string is given for
114
     *     a new menu, no change is performed,
115
     *     but the ID cache is cleared anyway.
116
     *
117
     * @see static::clearIdCache()
118
     */
119
    public function setMenu($newMenu)
120
    {
121
        $newMenu = (string)$newMenu;
122
        if ('' !== $newMenu) {
123
            $menuRequest = new Request('/menu');
124
            if ('/' === $newMenu) {
125
                $this->menu = '';
126
            } elseif ('/' === $newMenu[0]) {
127
                $this->menu = $menuRequest->setCommand($newMenu)->getCommand();
128
            } else {
129
                $newMenu = substr(
130
                    $menuRequest->setCommand(
131
                        '/' .
132
                        str_replace('/', ' ', (string)substr($this->menu, 1)) .
133
                        ' ' .
134
                        str_replace('/', ' ', $newMenu)
135
                        . ' ?'
136
                    )->getCommand(),
137
                    1,
138
                    -2/*strlen('/?')*/
139
                );
140
                if ('' !== $newMenu) {
141
                    $this->menu = '/' . $newMenu;
142
                } else {
143
                    $this->menu = '';
144
                }
145
            }
146
        }
147
        $this->clearIdCache();
148
        return $this;
149
    }
150
151
    /**
152
     * Creates a Request object.
153
     *
154
     * Creates a {@link Request} object, with a command that's at the
155
     * current menu. The request can then be sent using {@link Client}.
156
     *
157
     * @param string      $command The command of the request, not including
158
     *     the menu. The request will have that command at the current menu.
159
     * @param array       $args    Arguments of the request.
160
     *     Each array key is the name of the argument, and each array value is
161
     *     the value of the argument to be passed.
162
     *     Arguments without a value (i.e. empty arguments) can also be
163
     *     specified using a numeric key, and the name of the argument as the
164
     *     array value.
165
     * @param Query|null  $query   The {@link Query} of the request.
166
     * @param string|null $tag     The tag of the request.
167
     *
168
     * @return Request The {@link Request} object.
169
     *
170
     * @throws NotSupportedException On an attempt to call a command in a
171
     *     different menu using API syntax.
172
     * @throws InvalidArgumentException On an attempt to call a command in a
173
     *     different menu using CLI syntax.
174
     */
175
    public function newRequest(
176
        $command,
177
        array $args = array(),
178
        Query $query = null,
179
        $tag = null
180
    ) {
181
        if (false !== strpos($command, '/')) {
182
            throw new NotSupportedException(
183
                'Command tried to go to a different menu',
184
                NotSupportedException::CODE_MENU_MISMATCH,
185
                null,
186
                $command
187
            );
188
        }
189
        $request = new Request('/menu', $query, $tag);
190
        $request->setCommand("{$this->menu}/{$command}");
191
        foreach ($args as $name => $value) {
192
            if (is_int($name)) {
193
                $request->setArgument($value);
194
            } else {
195
                $request->setArgument($name, $value);
196
            }
197
        }
198
        return $request;
199
    }
200
201
    /**
202
     * Executes a RouterOS script.
203
     *
204
     * Executes a RouterOS script, written as a string or a stream.
205
     * Note that in cases of errors, the line numbers will be off, because the
206
     * script is executed at the current menu as context, with the specified
207
     * variables pre declared. This is achieved by prepending 1+count($params)
208
     * lines before your actual script.
209
     *
210
     * @param string|resource     $source The source of the script, as a string
211
     *     or stream. If a stream is provided, reading starts from the current
212
     *     position to the end of the stream, and the pointer stays at the end
213
     *     after reading is done.
214
     * @param array<string,mixed> $params An array of parameters to make
215
     *     available in the script as local variables.
216
     *     Variable names are array keys, and variable values are array values.
217
     *     Array values are automatically processed with
218
     *     {@link static::escapeValue()}. Streams are also supported, and are
219
     *     processed in chunks, each processed with
220
     *     {@link static::escapeString()}. Processing starts from the current
221
     *     position to the end of the stream, and the stream's pointer is left
222
     *     untouched after the reading is done.
223
     *     Note that the script's (generated) name is always added as the
224
     *     variable "_", which will be inadvertently lost if you overwrite it
225
     *     from here.
226
     * @param string|null         $policy Allows you to specify a policy the
227
     *     script must follow. Has the same format as in terminal.
228
     *     If left NULL, the script has no restrictions beyond those imposed by
229
     *     the username.
230
     * @param string|null         $name   The script is executed after being
231
     *     saved in "/system script" and is removed after execution.
232
     *     If this argument is left NULL, a random string,
233
     *     prefixed with the computer's name, is generated and used
234
     *     as the script's name.
235
     *     To eliminate any possibility of name clashes,
236
     *     you can specify your own name instead.
237
     *
238
     * @return string|resource The source of the script, as it appears after
239
     *     the run, right before it is removed.
240
     *     This can be used for easily retrieving basic output,
241
     *     by modifying the script from inside the script
242
     *     (use the $"_" variable to refer to the script's name within the
243
     *     "/system script" menu).
244
     * 
245
     * @throws RouterErrorException When there is an error in any step of the
246
     *     way. The reponses include all successful commands prior to the error
247
     *     as well.
248
     */
249
    public function exec(
250
        $source,
251
        array $params = array(),
252
        $policy = null,
253
        $name = null
254
    ) {
255
        if (null === $name) {
256
            $name = uniqid(gethostname(), true);
257
        }
258
259
        $request = new Request('/system/script/add');
260
        $request->setArgument('name', $name);
261
        $request->setArgument('policy', $policy);
262
263
        $params += array('_' => $name);
264
265
        $finalSource = fopen('php://temp', 'r+b');
266
        fwrite(
267
            $finalSource,
268
            '/' . str_replace('/', ' ', substr($this->menu, 1)). "\n"
269
        );
270
        Script::append($finalSource, $source, $params);
271
        fwrite($finalSource, "\n");
272
        rewind($finalSource);
273
274
        $request->setArgument('source', $finalSource);
275
        $addResult = $this->client->sendSync($request);
276
277
        if (count($addResult->getAllOfType(Response::TYPE_ERROR)) > 0) {
278
            throw new RouterErrorException(
279
                'Error when trying to add script',
280
                RouterErrorException::CODE_SCRIPT_ADD_ERROR,
281
                null,
282
                $addResult
283
            );
284
        }
285
286
        $request = new Request('/system/script/run');
287
        $request->setArgument('number', $name);
288
        $runResult = $this->client->sendSync($request);
289
        if (count($runResult->getAllOfType(Response::TYPE_ERROR)) > 0) {
290
            throw new RouterErrorException(
291
                'Error when running script',
292
                RouterErrorException::CODE_SCRIPT_RUN_ERROR,
293
                null,
294
                new ResponseCollection(
295
                    array_merge($addResult->toArray(), $runResult->toArray())
296
                )
297
            );
298
        }
299
300
        $request = new Request('/system/script/get');
301
        $request->setArgument('number', $name);
302
        $request->setArgument('value-name', 'source');
303
        $getResult = $this->client->sendSync($request);
304
        if (count($getResult->getAllOfType(Response::TYPE_ERROR)) > 0) {
305
            throw new RouterErrorException(
306
                'Error when getting script source',
307
                RouterErrorException::CODE_SCRIPT_GET_ERROR,
308
                null,
309
                new ResponseCollection(
310
                    array_merge(
311
                        $addResult->toArray(),
312
                        $runResult->toArray(),
313
                        $getResult->toArray()
314
                    )
315
                )
316
            );
317
        }
318
        $postSource = $getResult->end()->getProperty('ret');
319
        
320
        $request = new Request('/system/script/remove');
321
        $request->setArgument('numbers', $name);
322
        $removeResult = $this->client->sendSync($request);
323
        if (count($removeResult->getAllOfType(Response::TYPE_ERROR)) > 0) {
324
            throw new RouterErrorException(
325
                'Error when getting script source',
326
                RouterErrorException::CODE_SCRIPT_GET_ERROR,
327
                null,
328
                new ResponseCollection(
329
                    array_merge(
330
                        $addResult->toArray(),
331
                        $runResult->toArray(),
332
                        $getResult->toArray(),
333
                        $removeResult->toArray()
334
                    )
335
                )
336
            );
337
        }
338
        return $postSource;
339
    }
340
341
    /**
342
     * Clears the ID cache.
343
     *
344
     * Normally, the ID cache improves performance when targeting items by a
345
     * number. If you're using both Util's methods and other means (e.g.
346
     * {@link Client} or {@link Util::exec()}) to add/move/remove items, the
347
     * cache may end up being out of date. By calling this method right before
348
     * targeting an item with a number, you can ensure number accuracy.
349
     *
350
     * Note that Util's {@link static::move()} and {@link static::remove()}
351
     * methods automatically clear the cache before returning, while
352
     * {@link static::add()} adds the new item's ID to the cache as the next
353
     * number. A change in the menu also clears the cache.
354
     *
355
     * Note also that the cache is being rebuilt unconditionally every time you
356
     * use {@link static::find()} with a callback.
357
     *
358
     * @return $this The Util object itself.
359
     */
360
    public function clearIdCache()
361
    {
362
        $this->idCache = null;
363
        return $this;
364
    }
365
366
    /**
367
     * Gets the current time on the router.
368
     *
369
     * Gets the current time on the router, regardless of the current menu.
370
     *
371
     * If the timezone is one known to both RouterOS and PHP, it will be used
372
     * as the timezone identifier. Otherwise (e.g. "manual"), the current GMT
373
     * offset will be used as a timezone, without any DST awareness.
374
     *
375
     * @return DateTime The current time of the router, as a DateTime object.
376
     */
377
    public function getCurrentTime()
378
    {
379
        $clock = $this->client->sendSync(
380
            new Request(
381
                '/system/clock/print
382
                .proplist=date,time,time-zone-name,gmt-offset'
383
            )
384
        )->current();
385
        $clockParts = array();
386
        foreach (array(
387
            'date',
388
            'time',
389
            'time-zone-name',
390
            'gmt-offset'
391
        ) as $clockPart) {
392
            $clockParts[$clockPart] = $clock->getProperty($clockPart);
393
            if (Stream::isStream($clockParts[$clockPart])) {
394
                $clockParts[$clockPart] = stream_get_contents(
395
                    $clockParts[$clockPart]
396
                );
397
            }
398
        }
399
        $datetime = ucfirst(strtolower($clockParts['date'])) . ' ' .
400
            $clockParts['time'];
401
        try {
402
            $result = DateTime::createFromFormat(
403
                'M/j/Y H:i:s',
404
                $datetime,
405
                new DateTimeZone($clockParts['time-zone-name'])
406
            );
407
        } catch (E $e) {
408
            $result = DateTime::createFromFormat(
409
                'M/j/Y H:i:s P',
410
                $datetime . ' ' . $clockParts['gmt-offset'],
411
                new DateTimeZone('UTC')
412
            );
413
        }
414
        return $result;
415
    }
416
417
    /**
418
     * Finds the IDs of items at the current menu.
419
     *
420
     * Finds the IDs of items based on specified criteria, and returns them as
421
     * a comma separated string, ready for insertion at a "numbers" argument.
422
     *
423
     * Accepts zero or more criteria as arguments. If zero arguments are
424
     * specified, returns all items' IDs. The value of each criteria can be a
425
     * number (just as in Winbox), a literal ID to be included, a {@link Query}
426
     * object, or a callback. If a callback is specified, it is called for each
427
     * item, with the item as an argument. If it returns a true value, the
428
     * item's ID is included in the result. Every other value is casted to a
429
     * string. A string is treated as a comma separated values of IDs, numbers
430
     * or callback names. Non-existent callback names are instead placed in the
431
     * result, which may be useful in menus that accept identifiers other than
432
     * IDs, but note that it can cause errors on other menus.
433
     *
434
     * @return string A comma separated list of all items matching the
435
     *     specified criteria.
436
     */
437
    public function find()
438
    {
439
        if (func_num_args() === 0) {
440
            if (null === $this->idCache) {
441
                $ret = $this->client->sendSync(
442
                    new Request($this->menu . '/find')
443
                )->getProperty('ret');
444
                if (null === $ret) {
445
                    $this->idCache = array();
446
                    return '';
447
                } elseif (!is_string($ret)) {
448
                    $ret = stream_get_contents($ret);
449
                }
450
451
                $idCache = str_replace(
452
                    ';',
453
                    ',',
454
                    strtolower($ret)
455
                );
456
                $this->idCache = explode(',', $idCache);
457
                return $idCache;
458
            }
459
            return implode(',', $this->idCache);
460
        }
461
        $idList = '';
462
        foreach (func_get_args() as $criteria) {
463
            if ($criteria instanceof Query) {
464
                foreach ($this->client->sendSync(
465
                    new Request($this->menu . '/print .proplist=.id', $criteria)
466
                )->getAllOfType(Response::TYPE_DATA) as $response) {
467
                    $newId = $response->getProperty('.id');
468
                    $idList .= strtolower(
469
                        is_string($newId)
470
                        ? $newId
471
                        : stream_get_contents($newId) . ','
472
                    );
473
                }
474
            } elseif (is_callable($criteria)) {
475
                $idCache = array();
476
                foreach ($this->client->sendSync(
477
                    new Request($this->menu . '/print')
478
                )->getAllOfType(Response::TYPE_DATA) as $response) {
479
                    $newId = $response->getProperty('.id');
480
                    $newId = strtolower(
481
                        is_string($newId)
482
                        ? $newId
483
                        : stream_get_contents($newId)
484
                    );
485
                    if ($criteria($response)) {
486
                        $idList .= $newId . ',';
487
                    }
488
                    $idCache[] = $newId;
489
                }
490
                $this->idCache = $idCache;
491
            } else {
492
                $this->find();
493
                if (is_int($criteria)) {
494
                    if (isset($this->idCache[$criteria])) {
495
                        $idList = $this->idCache[$criteria] . ',';
496
                    }
497
                } else {
498
                    $criteria = (string)$criteria;
499
                    if ($criteria === (string)(int)$criteria) {
500
                        if (isset($this->idCache[(int)$criteria])) {
501
                            $idList .= $this->idCache[(int)$criteria] . ',';
502
                        }
503
                    } elseif (false === strpos($criteria, ',')) {
504
                        $idList .= $criteria . ',';
505
                    } else {
506
                        $criteriaArr = explode(',', $criteria);
507
                        for ($i = count($criteriaArr) - 1; $i >= 0; --$i) {
508
                            if ('' === $criteriaArr[$i]) {
509
                                unset($criteriaArr[$i]);
510
                            } elseif ('*' === $criteriaArr[$i][0]) {
511
                                $idList .= $criteriaArr[$i] . ',';
512
                                unset($criteriaArr[$i]);
513
                            }
514
                        }
515
                        if (!empty($criteriaArr)) {
516
                            $idList .= call_user_func_array(
517
                                array($this, 'find'),
518
                                $criteriaArr
519
                            ) . ',';
520
                        }
521
                    }
522
                }
523
            }
524
        }
525
        return rtrim($idList, ',');
526
    }
527
528
    /**
529
     * Gets a value of a specified item at the current menu.
530
     *
531
     * @param int|string|null|Query $number    A number identifying the target
532
     *     item. Can also be an ID or (in some menus) name. For menus where
533
     *     there are no items (e.g. "/system identity"), you can specify NULL.
534
     *     You can also specify a query, in which case the first match will be
535
     *     considered the target item.
536
     * @param string|null           $valueName The name of the value to get.
537
     *     If omitted, or set to NULL, gets all properties of the target item.
538
     *     Note that for versions that don't support omitting $valueName
539
     *     natively, a "print" with "detail" argument is used as a fallback,
540
     *     which may not contain certain properties. 
541
     *
542
     * @return string|resource|null|array The value of the specified
543
     *     property as a string or as new PHP temp stream if the underlying
544
     *     {@link Client::isStreamingResponses()} is set to TRUE.
545
     *     If the property is not set, NULL will be returned.
546
     *     If $valueName is NULL, returns all properties as an array, where
547
     *     the result is parsed with {@link Script::parseValueToArray()}.
548
     * 
549
     * @throws RouterErrorException When the router returns an error response
550
     *     (e.g. no such item, invalid property, etc.).
551
     */
552
    public function get($number, $valueName = null)
553
    {
554
        if (is_int($number) || ((string)$number === (string)(int)$number)) {
555
            $this->find();
556
            if (isset($this->idCache[(int)$number])) {
557
                $number = $this->idCache[(int)$number];
558
            } else {
559
                throw new RouterErrorException(
560
                    'Unable to resolve number from ID cache (no such item maybe)',
561
                    RouterErrorException::CODE_CACHE_ERROR
562
                );
563
            }
564
        } elseif ($number instanceof Query) {
565
            $number = explode(',', $this->find($number));
566
            $number = $number[0];
567
        }
568
569
        $request = new Request($this->menu . '/get');
570
        $request->setArgument('number', $number);
571
        $request->setArgument('value-name', $valueName);
572
        $responses = $this->client->sendSync($request);
573
        if (Response::TYPE_ERROR === $responses->getType()) {
574
            throw new RouterErrorException(
575
                'Error getting property',
576
                RouterErrorException::CODE_GET_ERROR,
577
                null,
578
                $responses
579
            );
580
        }
581
582
        $result = $responses->getProperty('ret');
583
        if (Stream::isStream($result)) {
584
            $result = stream_get_contents($result);
585
        }
586
        if (null === $valueName) {
587
            //Some earlier RouterOS versions use "," instead of ";" as separator
588
            if (false === strpos($result, ';')
589
                && 1 === preg_match('/^([^=,]+\=[^=,]*)(?:\,(?1))+$/', $result)
590
            ) {
591
                $result = str_replace(',', ';', $result);
592
            }
593
            return Script::parseValueToArray('{' . $result . '}');
594
        }
595
        return $result;
596
    }
597
598
    /**
599
     * Enables all items at the current menu matching certain criteria.
600
     *
601
     * Zero or more arguments can be specified, each being a criteria.
602
     * If zero arguments are specified, enables all items.
603
     * See {@link static::find()} for a description of what criteria are
604
     * accepted.
605
     *
606
     * @return ResponseCollection Returns the response collection, allowing you
607
     *     to inspect the output. Current RouterOS versions don't return
608
     *     anything useful, but if future ones do, you can read it right away.
609
     * 
610
     * @throws RouterErrorException When the router returns one or more errors.
611
     */
612
    public function enable()
613
    {
614
        $responses = $this->doBulk('enable', func_get_args());
615
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
616
            throw new RouterErrorException(
617
                'Error when enabling items',
618
                RouterErrorException::CODE_ENABLE_ERROR,
619
                null,
620
                $responses
621
            );
622
        }
623
        return $responses;
624
    }
625
626
    /**
627
     * Disables all items at the current menu matching certain criteria.
628
     *
629
     * Zero or more arguments can be specified, each being a criteria.
630
     * If zero arguments are specified, disables all items.
631
     * See {@link static::find()} for a description of what criteria are
632
     * accepted.
633
     *
634
     * @return ResponseCollection Returns the response collection, allowing you
635
     *     to inspect the output. Current RouterOS versions don't return
636
     *     anything useful, but if future ones do, you can read it right away.
637
     * 
638
     * @throws RouterErrorException When the router returns one or more errors.
639
     */
640
    public function disable()
641
    {
642
        $responses = $this->doBulk('disable', func_get_args());
643
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
644
            throw new RouterErrorException(
645
                'Error when disabling items',
646
                RouterErrorException::CODE_DISABLE_ERROR,
647
                null,
648
                $responses
649
            );
650
        }
651
        return $responses;
652
    }
653
654
    /**
655
     * Removes all items at the current menu matching certain criteria.
656
     *
657
     * Zero or more arguments can be specified, each being a criteria.
658
     * If zero arguments are specified, removes all items.
659
     * See {@link static::find()} for a description of what criteria are
660
     * accepted.
661
     *
662
     * @return ResponseCollection Returns the response collection, allowing you
663
     *     to inspect the output. Current RouterOS versions don't return
664
     *     anything useful, but if future ones do, you can read it right away.
665
     * 
666
     * @throws RouterErrorException When the router returns one or more errors.
667
     */
668
    public function remove()
669
    {
670
        $responses = $this->doBulk('remove', func_get_args());
671
        $this->clearIdCache();
672
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
673
            throw new RouterErrorException(
674
                'Error when removing items',
675
                RouterErrorException::CODE_REMOVE_ERROR,
676
                null,
677
                $responses
678
            );
679
        }
680
        return $responses;
681
    }
682
683
    /**
684
     * Comments items.
685
     *
686
     * Sets new comments on all items at the current menu
687
     * which match certain criteria, using the "comment" command.
688
     *
689
     * Note that not all menus have a "comment" command. Most notably, those are
690
     * menus without items in them (e.g. "/system identity"), and menus with
691
     * fixed items (e.g. "/ip service").
692
     *
693
     * @param mixed           $numbers Targeted items. Can be any criteria
694
     *     accepted by {@link static::find()}.
695
     * @param string|resource $comment The new comment to set on the item as a
696
     *     string or a seekable stream.
697
     *     If a seekable stream is provided, it is sent from its current
698
     *     position to its end, and the pointer is seeked back to its current
699
     *     position after sending.
700
     *     Non seekable streams, as well as all other types, are casted to a
701
     *     string.
702
     *
703
     * @return ResponseCollection Returns the response collection, allowing you
704
     *     to inspect the output. Current RouterOS versions don't return
705
     *     anything useful, but if future ones do, you can read it right away.
706
     * 
707
     * @throws RouterErrorException When the router returns one or more errors.
708
     */
709
    public function comment($numbers, $comment)
710
    {
711
        $commentRequest = new Request($this->menu . '/comment');
712
        $commentRequest->setArgument('comment', $comment);
713
        $commentRequest->setArgument('numbers', $this->find($numbers));
714
        $responses = $this->client->sendSync($commentRequest);
715
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
716
            throw new RouterErrorException(
717
                'Error when commenting items',
718
                RouterErrorException::CODE_COMMENT_ERROR,
719
                null,
720
                $responses
721
            );
722
        }
723
        return $responses;
724
    }
725
726
    /**
727
     * Sets new values.
728
     *
729
     * Sets new values on certain properties on all items at the current menu
730
     * which match certain criteria.
731
     *
732
     * @param mixed                                           $numbers   Items
733
     *     to be modified.
734
     *     Can be any criteria accepted by {@link static::find()} or NULL
735
     *     in case the menu is one without items (e.g. "/system identity").
736
     * @param array<string,string|resource>|array<int,string> $newValues An
737
     *     array with the names of each property to set as an array key, and the
738
     *     new value as an array value.
739
     *     Flags (properties with a value "true" that is interpreted as
740
     *     equivalent of "yes" from CLI) can also be specified with a numeric
741
     *     index as the array key, and the name of the flag as the array value.
742
     *
743
     * @return ResponseCollection Returns the response collection, allowing you
744
     *     to inspect errors, if any.
745
     */
746
    public function set($numbers, array $newValues)
747
    {
748
        $setRequest = new Request($this->menu . '/set');
749
        foreach ($newValues as $name => $value) {
750
            if (is_int($name)) {
751
                $setRequest->setArgument($value, 'true');
752
            } else {
753
                $setRequest->setArgument($name, $value);
754
            }
755
        }
756
        if (null !== $numbers) {
757
            $setRequest->setArgument('numbers', $this->find($numbers));
758
        }
759
        $responses = $this->client->sendSync($setRequest);
760
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
761
            throw new RouterErrorException(
762
                'Error when setting items',
763
                RouterErrorException::CODE_SET_ERROR,
764
                null,
765
                $responses
766
            );
767
        }
768
        return $responses;
769
    }
770
771
    /**
772
     * Alias of {@link static::set()}
773
     *
774
     * @param mixed                $numbers   Items to be modified.
775
     *     Can be any criteria accepted by {@link static::find()} or NULL
776
     *     in case the menu is one without items (e.g. "/system identity").
777
     * @param string               $valueName Name of property to be modified.
778
     * @param string|resource|null $newValue  The new value to set.
779
     *     If set to NULL, the property is unset.
780
     *
781
     * @return ResponseCollection Returns the response collection, allowing you
782
     *     to inspect errors, if any.
783
     */
784
    public function edit($numbers, $valueName, $newValue)
785
    {
786
        return null === $newValue
787
            ? $this->unsetValue($numbers, $valueName)
788
            : $this->set($numbers, array($valueName => $newValue));
789
    }
790
791
    /**
792
     * Unsets a value of a specified item at the current menu.
793
     *
794
     * Equivalent of scripting's "unset" command. The "Value" part in the method
795
     * name is added because "unset" is a language construct, and thus a
796
     * reserved word.
797
     *
798
     * @param mixed  $numbers   Targeted items. Can be any criteria accepted
799
     *     by {@link static::find()}.
800
     * @param string $valueName The name of the value you want to unset.
801
     *
802
     * @return ResponseCollection Returns the response collection, allowing you
803
     *     to inspect errors, if any.
804
     */
805
    public function unsetValue($numbers, $valueName)
806
    {
807
        $unsetRequest = new Request($this->menu . '/unset');
808
        $responses = $this->client->sendSync(
809
            $unsetRequest->setArgument('numbers', $this->find($numbers))
810
                ->setArgument('value-name', $valueName)
811
        );
812
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
813
            throw new RouterErrorException(
814
                'Error when unsetting value of items',
815
                RouterErrorException::CODE_UNSET_ERROR,
816
                null,
817
                $responses
818
            );
819
        }
820
        return $responses;
821
    }
822
823
    /**
824
     * Adds a new item at the current menu.
825
     *
826
     * @param array<string,string|resource>|array<int,string> $values Accepts
827
     *     one or more items to add to the current menu.
828
     *     The data about each item is specified as an array with the names of
829
     *     each property as an array key, and the value as an array value.
830
     *     Flags (properties with a value "true" that is interpreted as
831
     *     equivalent of "yes" from CLI) can also be specified with a numeric
832
     *     index as the array key, and the name of the flag as the array value.
833
     * @param array<string,string|resource>|array<int,string> $...    Additional
834
     *     items.
835
     *
836
     * @return string A comma separated list of the new items' IDs.
837
     * 
838
     * @throws RouterErrorException When one or more items were not succesfully
839
     *     added. Note that the response collection will include all replies of
840
     *     all add commands, including the successful ones, in order.
841
     */
842
    public function add(array $values)
843
    {
844
        $addRequest = new Request($this->menu . '/add');
845
        $hasErrors = false;
846
        $results = array();
847
        foreach (func_get_args() as $values) {
848
            if (!is_array($values)) {
849
                continue;
850
            }
851
            foreach ($values as $name => $value) {
852
                if (is_int($name)) {
853
                    $addRequest->setArgument($value, 'true');
854
                } else {
855
                    $addRequest->setArgument($name, $value);
856
                }
857
            }
858
            $result = $this->client->sendSync($addRequest);
859
            if (count($result->getAllOfType(Response::TYPE_ERROR)) > 0) {
860
                $hasErrors = true;
861
            }
862
            $results = array_merge($results, $result->toArray());
863
            $addRequest->removeAllArguments();
864
        }
865
866
        $this->clearIdCache();
867
        if ($hasErrors) {
868
            throw new RouterErrorException(
869
                'Router returned error when adding items',
870
                RouterErrorException::CODE_ADD_ERROR,
871
                null,
872
                new ResponseCollection($results)
873
            );
874
        }
875
        $results = new ResponseCollection($results);
876
        $idList = '';
877
        foreach ($results->getAllOfType(Response::TYPE_FINAL) as $final) {
878
            $idList .= ',' . strtolower($final->getProperty('ret'));
879
        }
880
        return substr($idList, 1);
881
    }
882
883
    /**
884
     * Moves items at the current menu before a certain other item.
885
     *
886
     * Moves items before a certain other item. Note that the "move"
887
     * command is not available on all menus. As a rule of thumb, if the order
888
     * of items in a menu is irrelevant to their interpretation, there won't
889
     * be a move command on that menu. If in doubt, check from a terminal.
890
     *
891
     * @param mixed $numbers     Targeted items. Can be any criteria accepted
892
     *     by {@link static::find()}.
893
     * @param mixed $destination Item before which the targeted items will be
894
     *     moved to. Can be any criteria accepted by {@link static::find()}.
895
     *     If multiple items match the criteria, the targeted items will move
896
     *     above the first match.
897
     *     If NULL is given (or this argument is omitted), the targeted items
898
     *     will be moved to the bottom of the menu.
899
     *
900
     * @return ResponseCollection Returns the response collection, allowing you
901
     *     to inspect errors, if any.
902
     */
903
    public function move($numbers, $destination = null)
904
    {
905
        $moveRequest = new Request($this->menu . '/move');
906
        $moveRequest->setArgument('numbers', $this->find($numbers));
907
        if (null !== $destination) {
908
            $destination = $this->find($destination);
909
            if (false !== strpos($destination, ',')) {
910
                $destination = strstr($destination, ',', true);
911
            }
912
            $moveRequest->setArgument('destination', $destination);
913
        }
914
        $this->clearIdCache();
915
        $responses = $this->client->sendSync($moveRequest);
916
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
917
            throw new RouterErrorException(
918
                'Error when moving items',
919
                RouterErrorException::CODE_MOVE_ERROR,
920
                null,
921
                $responses
922
            );
923
        }
924
        return $responses;
925
    }
926
927
    /**
928
     * Counts items at the current menu.
929
     *
930
     * Counts items at the current menu. This executes a dedicated command
931
     * ("print" with a "count-only" argument) on RouterOS, which is why only
932
     * queries are allowed as a criteria, in contrast with
933
     * {@link static::find()}, where numbers and callbacks are allowed also.
934
     *
935
     * @param Query|null $query A query to filter items by. Without it, all items
936
     *     are included in the count.
937
     *
938
     * @return int The number of items, or -1 on failure (e.g. if the
939
     *     current menu does not have a "print" command or items to be counted).
940
     */
941
    public function count(Query $query = null)
942
    {
943
        $result = $this->client->sendSync(
944
            new Request($this->menu . '/print count-only=""', $query)
945
        )->end()->getProperty('ret');
946
947
        if (null === $result) {
948
            return -1;
949
        }
950
        if (Stream::isStream($result)) {
951
            $result = stream_get_contents($result);
952
        }
953
        return (int)$result;
954
    }
955
956
    /**
957
     * Gets all items in the current menu.
958
     *
959
     * Gets all items in the current menu, using a print request.
960
     *
961
     * @param array<string,string|resource>|array<int,string> $args  Additional
962
     *     arguments to pass to the request.
963
     *     Each array key is the name of the argument, and each array value is
964
     *     the value of the argument to be passed.
965
     *     Arguments without a value (i.e. empty arguments) can also be
966
     *     specified using a numeric key, and the name of the argument as the
967
     *     array value.
968
     *     The "follow" and "follow-only" arguments are prohibited,
969
     *     as they would cause a synchronous request to run forever, without
970
     *     allowing the results to be observed.
971
     *     If you need to use those arguments, use {@link static::newRequest()},
972
     *     and pass the resulting {@link Request} to {@link Client::sendAsync()}.
973
     *     The "count-only" argument is also prohibited, as results from it
974
     *     would not be consumable. Use {@link static::count()} for that.
975
     * @param Query|null                                      $query A query to
976
     *     filter items by.
977
     *     NULL to get all items.
978
     *
979
     * @return ResponseCollection A response collection with all
980
     *     {@link Response::TYPE_DATA} responses. The collection will be empty
981
     *     when there are no matching items.
982
     *
983
     * @throws NotSupportedException If $args contains prohibited arguments
984
     *     ("follow", "follow-only" or "count-only").
985
     *
986
     * @throws RouterErrorException When there's an error upon attempting to
987
     *     call the "print" command on the specified menu (e.g. if there's no
988
     *     "print" command at the menu to begin with).
989
     */
990
    public function getAll(array $args = array(), Query $query = null)
991
    {
992
        $printRequest = new Request($this->menu . '/print', $query);
993
        foreach ($args as $name => $value) {
994
            if (is_int($name)) {
995
                $printRequest->setArgument($value);
996
            } else {
997
                $printRequest->setArgument($name, $value);
998
            }
999
        }
1000
1001
        foreach (array('follow', 'follow-only', 'count-only') as $arg) {
1002
            if ($printRequest->getArgument($arg) !== null) {
1003
                throw new NotSupportedException(
1004
                    "The argument '{$arg}' was specified, but is prohibited",
1005
                    NotSupportedException::CODE_ARG_PROHIBITED,
1006
                    null,
1007
                    $arg
1008
                );
1009
            }
1010
        }
1011
        $responses = $this->client->sendSync($printRequest);
1012
1013
        if (count($responses->getAllOfType(Response::TYPE_ERROR)) > 0) {
1014
            throw new RouterErrorException(
1015
                'Error when reading items',
1016
                RouterErrorException::CODE_GETALL_ERROR,
1017
                null,
1018
                $responses
1019
            );
1020
        }
1021
        return $responses->getAllOfType(Response::TYPE_DATA);
1022
    }
1023
1024
    /**
1025
     * Puts a file on RouterOS's file system.
1026
     *
1027
     * Puts a file on RouterOS's file system, regardless of the current menu.
1028
     * Note that this is a **VERY VERY VERY** time consuming method - it takes a
1029
     * minimum of a little over 4 seconds, most of which are in sleep. It waits
1030
     * 2 seconds after a file is first created (required to actually start
1031
     * writing to the file), and another 2 seconds after its contents is written
1032
     * (performed in order to verify success afterwards).
1033
     * Similarly for removal (when $data is NULL) - there are two seconds in
1034
     * sleep, used to verify the file was really deleted.
1035
     *
1036
     * If you want an efficient way of transferring files, use (T)FTP.
1037
     * If you want an efficient way of removing files, use
1038
     * {@link static::setMenu()} to move to the "/file" menu, and call
1039
     * {@link static::remove()} without performing verification afterwards.
1040
     *
1041
     * @param string               $filename  The filename to write data in.
1042
     * @param string|resource|null $data      The data the file is going to have
1043
     *     as a string or a seekable stream.
1044
     *     Setting the value to NULL removes a file of this name.
1045
     *     If a seekable stream is provided, it is sent from its current
1046
     *     position to its end, and the pointer is seeked back to its current
1047
     *     position after sending.
1048
     *     Non seekable streams, as well as all other types, are casted to a
1049
     *     string.
1050
     * @param bool                 $overwrite Whether to overwrite the file if
1051
     *     it exists.
1052
     *
1053
     * @return bool TRUE on success, FALSE on failure.
1054
     */
1055
    public function filePutContents($filename, $data, $overwrite = false)
1056
    {
1057
        $printRequest = new Request(
1058
            '/file/print .proplist=""',
1059
            Query::where('name', $filename)
1060
        );
1061
        $fileExists = count($this->client->sendSync($printRequest)) > 1;
1062
1063
        if (null === $data) {
1064
            if (!$fileExists) {
1065
                return false;
1066
            }
1067
            $removeRequest = new Request('/file/remove');
1068
            $this->client->sendSync(
1069
                $removeRequest->setArgument('numbers', $filename)
1070
            );
1071
            //Required for RouterOS to REALLY remove the file.
1072
            sleep(2);
1073
            return !(count($this->client->sendSync($printRequest)) > 1);
1074
        }
1075
1076
        if (!$overwrite && $fileExists) {
1077
            return false;
1078
        }
1079
        $result = $this->client->sendSync(
1080
            $printRequest->setArgument('file', $filename)
1081
        );
1082
        if (count($result->getAllOfType(Response::TYPE_ERROR)) > 0) {
1083
            return false;
1084
        }
1085
        //Required for RouterOS to write the initial file.
1086
        sleep(2);
1087
        $setRequest = new Request('/file/set contents=""');
1088
        $setRequest->setArgument('numbers', $filename);
1089
        $this->client->sendSync($setRequest);
1090
        $this->client->sendSync($setRequest->setArgument('contents', $data));
1091
        //Required for RouterOS to write the file's new contents.
1092
        sleep(2);
1093
1094
        $fileSize = $this->client->sendSync(
1095
            $printRequest->setArgument('file', null)
1096
                ->setArgument('.proplist', 'size')
1097
        )->getProperty('size');
1098
        if (Stream::isStream($fileSize)) {
1099
            $fileSize = stream_get_contents($fileSize);
1100
        }
1101
        if (Communicator::isSeekableStream($data)) {
1102
            return Communicator::seekableStreamLength($data) == $fileSize;
1103
        } else {
1104
            return sprintf('%u', strlen((string)$data)) === $fileSize;
1105
        }
1106
    }
1107
1108
    /**
1109
     * Gets the contents of a specified file.
1110
     *
1111
     * @param string      $filename      The name of the file to get
1112
     *     the contents of.
1113
     * @param string|null $tmpScriptName In order to get the file's contents, a
1114
     *     script is created at "/system script", the source of which is then
1115
     *     overwritten with the file's contents, then retrieved from there,
1116
     *     after which the script is removed.
1117
     *     If this argument is left NULL, a random string,
1118
     *     prefixed with the computer's name, is generated and used
1119
     *     as the script's name.
1120
     *     To eliminate any possibility of name clashes,
1121
     *     you can specify your own name instead.
1122
     *
1123
     * @return string|resource|false The contents of the file as a string or as
1124
     *     new PHP temp stream if the underlying
1125
     *     {@link Client::isStreamingResponses()} is set to TRUE.
1126
     *     FALSE is returned if there is no such file.
1127
     *
1128
     * @throws RouterErrorException When there's an error with the temporary
1129
     *     script used to get the file.
1130
     */
1131
    public function fileGetContents($filename, $tmpScriptName = null)
1132
    {
1133
        $checkRequest = new Request(
1134
            '/file/print .proplist=""',
1135
            Query::where('name', $filename)
1136
        );
1137
        if (1 === count($this->client->sendSync($checkRequest))) {
1138
            return false;
1139
        }
1140
        return $this->exec(
1141
            '/system script set $"_" source=[/file get $filename contents]',
1142
            array('filename' => $filename),
1143
            null,
1144
            $tmpScriptName
1145
        );
1146
    }
1147
1148
    /**
1149
     * Performs an action on a bulk of items at the current menu.
1150
     *
1151
     * @param string $command What command to perform.
1152
     * @param array  $args    Zero or more arguments can be specified,
1153
     *     each being a criteria.
1154
     *     If zero arguments are specified, matches all items.
1155
     *     See {@link static::find()} for a description of what criteria are
1156
     *     accepted.
1157
     *
1158
     * @return ResponseCollection Returns the response collection, allowing you
1159
     *     to inspect errors, if any.
1160
     */
1161
    protected function doBulk($command, array $args = array())
1162
    {
1163
        $bulkRequest = new Request("{$this->menu}/{$command}");
1164
        $bulkRequest->setArgument(
1165
            'numbers',
1166
            call_user_func_array(array($this, 'find'), $args)
1167
        );
1168
        return $this->client->sendSync($bulkRequest);
1169
    }
1170
}
1171