<?php /** * RouterOS API client implementation. * * RouterOS is the flag product of the company MikroTik and is a powerful router software. One of its many abilities is to allow control over it via an API. This package provides a client for that API, in turn allowing you to use PHP to control RouterOS hosts. * * PHP version 5 * * @category Net * @package PEAR2_Net_RouterOS * @author Vasil Rangelov <boen.robot@gmail.com> * @copyright 2011 Vasil Rangelov * @license http://www.gnu.org/copyleft/lesser.html LGPL License 2.1 * @version 1.0.0b6 * @link http://pear2.php.net/PEAR2_Net_RouterOS */ /** * The namespace declaration. */ namespace PEAR2\Net\RouterOS; /** * Values at {@link Script::escapeValue()} can be casted from this type. */ use DateTime; /** * Values at {@link Script::escapeValue()} can be casted from this type. */ use DateInterval; /** * Used at {@link Script::escapeValue()} to get the proper time. */ use DateTimeZone; /** * Used to reliably write to streams at {@link Script::prepare()}. */ use PEAR2\Net\Transmitter\Stream; /** * Used to catch DateTime and DateInterval exceptions at * {@link Script::parseValue()}. */ use Exception as E; /** * Scripting class. * * Provides functionality related to parsing and composing RouterOS scripts and * values. * * @category Net * @package PEAR2_Net_RouterOS * @author Vasil Rangelov <boen.robot@gmail.com> * @license http://www.gnu.org/copyleft/lesser.html LGPL License 2.1 * @link http://pear2.php.net/PEAR2_Net_RouterOS */ class Script { /** * Parses a value from a RouterOS scripting context. * * Turns a value from RouterOS into an equivalent PHP value, based on * determining the type in the same way RouterOS would determine it for a * literal. * * This method is intended to be the very opposite of * {@link static::escapeValue()}. That is, results from that method, if * given to this method, should produce equivalent results. * * @param string $value The value to be parsed. * Must be a literal of a value, * e.g. what {@link static::escapeValue()} will give you. * @param DateTimeZone|null $timezone The timezone which any resulting * DateTime object (either the main value, or values within an array) * will use. Defaults to UTC. * * @return mixed Depending on RouterOS type detected: * - "nil" (the string "[]") or "nothing" (empty string) - NULL. * - "num" - int or double for large values. * - "bool" - a boolean. * - "array" - an array, with the keys and values processed recursively. * - "time" - a {@link DateInterval} object. * - "date" (pseudo type; string in the form "M/j/Y") - a DateTime * object with the specified date, at midnight. * - "datetime" (pseudo type; string in the form "M/j/Y H:i:s") - a * DateTime object with the specified date and time. * - "str" (a quoted string) - a string, with the contents escaped. * - Unrecognized type - casted to a string, unmodified. */ public static function parseValue($value, DateTimeZone $timezone = null) { $value = static::parseValueToSimple($value); if (!is_string($value)) { return $value; } try { return static::parseValueToArray($value, $timezone); } catch (ParserException $e) { try { return static::parseValueToDateInterval($value); } catch (ParserException $e) { try { return static::parseValueToDateTime($value, $timezone); } catch (ParserException $e) { return static::parseValueToString($value); } } } } /** * Parses a RouterOS value into a PHP string. * * @param string $value The value to be parsed. * Must be a literal of a value, * e.g. what {@link static::escapeValue()} will give you. * * @return string If a quoted string is provided, it would be parsed. * Otherwise, the value is casted to a string, and returned unmodified. */ public static function parseValueToString($value) { $value = (string)$value; if ('"' === $value[0] && '"' === $value[strlen($value) - 1]) { return str_replace( array('\"', '\\\\', "\\\n", "\\\r\n", "\\\r"), array('"', '\\'), substr($value, 1, -1) ); } return $value; } /** * Parses a RouterOS value into a PHP simple type. * * Parses a RouterOS value into a PHP simple type. "Simple" types being * scalar types, plus NULL. * * @param string $value The value to be parsed. Must be a literal of a * value, e.g. what {@link static::escapeValue()} will give you. * * @return string|bool|int|double|null Depending on RouterOS type detected: * - "nil" (the string "[]") or "nothing" (empty string) - NULL. * - "num" - int or double for large values. * - "bool" - a boolean. * - Unrecognized type - casted to a string, unmodified. */ public static function parseValueToSimple($value) { $value = (string)$value; if (in_array($value, array('', '[]'), true)) { return null; } elseif (in_array($value, array('true', 'false', 'yes', 'no'), true)) { return $value === 'true' || $value === 'yes'; } elseif ($value === (string)($num = (int)$value) || $value === (string)($num = (double)$value) ) { return $num; } return $value; } /** * Parses a RouterOS value into a PHP DateTime object * * Parses a RouterOS value into a PHP DateTime object. * * @param string $value The value to be parsed. * Must be a literal of a value, * e.g. what {@link static::escapeValue()} will give you. * @param DateTimeZone|null $timezone The timezone which the resulting * DateTime object will use. Defaults to UTC. * * @return DateTime Depending on RouterOS type detected: * - "date" (pseudo type; string in the form "M/j/Y") - a DateTime * object with the specified date, at midnight UTC time (regardless * of timezone provided). * - "datetime" (pseudo type; string in the form "M/j/Y H:i:s") - a * DateTime object with the specified date and time, * with the specified timezone. * * @throws ParserException When the value is not of a recognized type. */ public static function parseValueToDateTime( $value, DateTimeZone $timezone = null ) { $previous = null; $value = (string)$value; if ('' !== $value && preg_match( '#^ (?<mon>jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec) / (?<day>\d\d?) / (?<year>\d{4}) (?: \s+(?<time>\d{2}\:\d{2}:\d{2}) )? $#uix', $value, $date )) { if (!isset($date['time'])) { $date['time'] = '00:00:00'; $timezone = new DateTimeZone('UTC'); } elseif (null === $timezone) { $timezone = new DateTimeZone('UTC'); } try { return new DateTime( $date['year'] . '-' . ucfirst($date['mon']) . "-{$date['day']} {$date['time']}", $timezone ); } catch (E $e) { $previous = $e; } } throw new ParserException( 'The supplied value can not be converted to a DateTime', ParserException::CODE_DATETIME, $previous ); } /** * Parses a RouterOS value into a PHP DateInterval. * * Parses a RouterOS value into a PHP DateInterval. * * @param string $value The value to be parsed. Must be a literal of a * value, e.g. what {@link static::escapeValue()} will give you. * * @return DateInterval The value as a DateInterval object. * * @throws ParserException When the value is not of a recognized type. */ public static function parseValueToDateInterval($value) { $value = (string)$value; if ('' !== $value && preg_match( '/^ (?:(\d+)w)? (?:(\d+)d)? (?:(\d+)(?:\:|h))? (?| (\d+)\: (\d*(?:\.\d{1,9})?) | (?:(\d+)m)? (?:(\d+|\d*\.\d{1,9})s)? (?:((?5))ms)? (?:((?5))us)? (?:((?5))ns)? ) $/x', $value, $time )) { $days = isset($time[2]) ? (int)$time[2] : 0; if (isset($time[1])) { $days += 7 * (int)$time[1]; } if (empty($time[3])) { $time[3] = 0; } if (empty($time[4])) { $time[4] = 0; } if (empty($time[5])) { $time[5] = 0; } $subsecondTime = 0.0; //@codeCoverageIgnoreStart // No PHP version currently supports sub-second DateIntervals, // meaning this section is untestable, since no version constraints // can be specified for test inputs. // All inputs currently use integer seconds only, making this // section unreachable during tests. // Nevertheless, this section exists right now, in order to provide // such support as soon as PHP has it. if (!empty($time[6])) { $subsecondTime += ((double)$time[6]) / 1000; } if (!empty($time[7])) { $subsecondTime += ((double)$time[7]) / 1000000; } if (!empty($time[8])) { $subsecondTime += ((double)$time[8]) / 1000000000; } //@codeCoverageIgnoreEnd $secondsSpec = $time[5] + $subsecondTime; try { return new DateInterval( "P{$days}DT{$time[3]}H{$time[4]}M{$secondsSpec}S" ); //@codeCoverageIgnoreStart // See previous ignored section's note. // // This section is added for backwards compatibility with current // PHP versions, when in the future sub-second support is added. // In that event, the test inputs for older versions will be // expected to get a rounded up result of the sub-second data. } catch (E $e) { $secondsSpec = (int)round($secondsSpec); return new DateInterval( "P{$days}DT{$time[3]}H{$time[4]}M{$secondsSpec}S" ); } //@codeCoverageIgnoreEnd } throw new ParserException( 'The supplied value can not be converted to DateInterval', ParserException::CODE_DATEINTERVAL ); } /** * Parses a RouterOS value into a PHP array. * * Parses a RouterOS value into a PHP array. * * @param string $value The value to be parsed. * Must be a literal of a value, * e.g. what {@link static::escapeValue()} will give you. * @param DateTimeZone|null $timezone The timezone which any resulting * DateTime object within the array will use. Defaults to UTC. * * @return array An array, with the keys and values processed recursively, * the keys with {@link static::parseValueToSimple()}, * and the values with {@link static::parseValue()}. * * @throws ParserException When the value is not of a recognized type. */ public static function parseValueToArray( $value, DateTimeZone $timezone = null ) { $value = (string)$value; if ('{' === $value[0] && '}' === $value[strlen($value) - 1]) { $value = substr($value, 1, -1); if ('' === $value) { return array(); } $parsedValue = preg_split( '/ (\"(?:\\\\\\\\|\\\\"|[^"])*\") | (\{[^{}]*(?2)?\}) | ([^;=]+) /sx', $value, null, PREG_SPLIT_DELIM_CAPTURE ); $result = array(); $newVal = null; $newKey = null; for ($i = 0, $l = count($parsedValue); $i < $l; ++$i) { switch ($parsedValue[$i]) { case '': break; case ';': if (null === $newKey) { $result[] = $newVal; } else { $result[$newKey] = $newVal; } $newKey = $newVal = null; break; case '=': $newKey = static::parseValueToSimple($parsedValue[$i - 1]); $newVal = static::parseValue($parsedValue[++$i], $timezone); break; default: $newVal = static::parseValue($parsedValue[$i], $timezone); } } if (null === $newKey) { $result[] = $newVal; } else { $result[$newKey] = $newVal; } return $result; } throw new ParserException( 'The supplied value can not be converted to an array', ParserException::CODE_ARRAY ); } /** * Prepares a script. * * Prepares a script for eventual execution by prepending parameters as * variables to it. * * This is particularly useful when you're creating scripts that you don't * want to execute right now (as with {@link Util::exec()}, but instead * you want to store it for later execution, perhaps by supplying it to * "/system scheduler". * * @param string|resource $source The source of the script, * as a string or stream. If a stream is provided, reading starts from * the current position to the end of the stream, and the pointer stays * at the end after reading is done. * @param array<string|int,mixed> $params An array of parameters to make * available in the script as local variables. * Variable names are array keys, and variable values are array values. * Array values are automatically processed with * {@link static::escapeValue()}. Streams are also supported, and are * processed in chunks, each with * {@link static::escapeString()} with all bytes being escaped. * Processing starts from the current position to the end of the stream, * and the stream's pointer is left untouched after the reading is done. * Variables with a value of type "nothing" can be declared with a * numeric array key and the variable name as the array value * (that is casted to a string). * * @return resource A new PHP temporary stream with the script as contents, * with the pointer back at the start. * * @see static::append() */ public static function prepare( $source, array $params = array() ) { $resultStream = fopen('php://temp', 'r+b'); static::append($resultStream, $source, $params); rewind($resultStream); return $resultStream; } /** * Appends a script. * * Appends a script to an existing stream. * * @param resource $stream An existing stream to write the * resulting script to. * @param string|resource $source The source of the script, * as a string or stream. If a stream is provided, reading starts from * the current position to the end of the stream, and the pointer stays * at the end after reading is done. * @param array<string|int,mixed> $params An array of parameters to make * available in the script as local variables. * Variable names are array keys, and variable values are array values. * Array values are automatically processed with * {@link static::escapeValue()}. Streams are also supported, and are * processed in chunks, each with * {@link static::escapeString()} with all bytes being escaped. * Processing starts from the current position to the end of the stream, * and the stream's pointer is left untouched after the reading is done. * Variables with a value of type "nothing" can be declared with a * numeric array key and the variable name as the array value * (that is casted to a string). * * @return int The number of bytes written to $stream is returned, * and the pointer remains where it was after the write * (i.e. it is not seeked back, even if seeking is supported). */ public static function append( $stream, $source, array $params = array() ) { $writer = new Stream($stream, false); $bytes = 0; foreach ($params as $pname => $pvalue) { if (is_int($pname)) { $pvalue = static::escapeString((string)$pvalue); $bytes += $writer->send(":local \"{$pvalue}\";\n"); continue; } $pname = static::escapeString($pname); $bytes += $writer->send(":local \"{$pname}\" "); if (Stream::isStream($pvalue)) { $reader = new Stream($pvalue, false); $chunkSize = $reader->getChunk(Stream::DIRECTION_RECEIVE); $bytes += $writer->send('"'); while ($reader->isAvailable() && $reader->isDataAwaiting()) { $bytes += $writer->send( static::escapeString(fread($pvalue, $chunkSize), true) ); } $bytes += $writer->send("\";\n"); } else { $bytes += $writer->send(static::escapeValue($pvalue) . ";\n"); } } $bytes += $writer->send($source); return $bytes; } /** * Escapes a value for a RouterOS scripting context. * * Turns any native PHP value into an equivalent whole value that can be * inserted as part of a RouterOS script. * * DateInterval objects will be casted to RouterOS' "time" type. * * DateTime objects will be casted to a string following the "M/d/Y H:i:s" * format. If the time is exactly midnight (including microseconds), and * the timezone is UTC, the string will include only the "M/d/Y" date. * * Unrecognized types (i.e. resources and other objects) are casted to * strings, and those strings are then escaped. * * @param mixed $value The value to be escaped. * * @return string A string representation that can be directly inserted in a * script as a whole value. */ public static function escapeValue($value) { switch(gettype($value)) { case 'NULL': $value = '[]'; break; case 'integer': $value = (string)$value; break; case 'boolean': $value = $value ? 'true' : 'false'; break; case 'array': if (0 === count($value)) { $value = '({})'; break; } $result = ''; foreach ($value as $key => $val) { $result .= ';'; if (!is_int($key)) { $result .= static::escapeValue($key) . '='; } $result .= static::escapeValue($val); } $value = '{' . substr($result, 1) . '}'; break; case 'object': if ($value instanceof DateTime) { $usec = $value->format('u'); $usec = '000000' === $usec ? '' : '.' . $usec; $value = '00:00:00.000000 UTC' === $value->format('H:i:s.u e') ? $value->format('M/d/Y') : $value->format('M/d/Y H:i:s') . $usec; } if ($value instanceof DateInterval) { if (false === $value->days || $value->days < 0) { $value = $value->format('%r%dd%H:%I:%S'); } else { $value = $value->format('%r%ad%H:%I:%S'); } break; } //break; intentionally omitted default: $value = '"' . static::escapeString((string)$value) . '"'; break; } return $value; } /** * Escapes a string for a RouterOS scripting context. * * Escapes a string for a RouterOS scripting context. The value can then be * surrounded with quotes at a RouterOS script (or concatenated onto a * larger string first), and you can be sure there won't be any code * injections coming from it. * * By default, for the sake of brevity of the output, ASCII alphanumeric * characters and underscores are left untouched. And for the sake of * character conversion, bytes above 0x7F are also left untouched. * * @param string $value Value to be escaped. * @param bool $full Whether to escape all bytes in the string, including * ASCII alphanumeric characters, underscores and bytes above 0x7F. * * @return string The escaped value. * * @internal Why leave ONLY those ASCII characters and not also others? * Because those can't in any way be mistaken for language constructs, * unlike many other "safe inside strings, but not outside" ASCII * characters, like ",", ".", "+", "-", "~", etc. */ public static function escapeString($value, $full = false) { if ($full) { return self::_escapeCharacters(array($value)); } return preg_replace_callback( '/[^\\_A-Za-z0-9\\x80-\\xFF]+/S', array(__CLASS__, '_escapeCharacters'), $value ); } /** * Escapes a character for a RouterOS scripting context. * * Escapes a character for a RouterOS scripting context. * Intended to only be called by {@link self::escapeString()} for the * matching strings. * * @param array $chars The matches array, expected to contain exactly one * member, in which is the whole string to be escaped. * * @return string The escaped characters. */ private static function _escapeCharacters(array $chars) { $result = ''; for ($i = 0, $l = strlen($chars[0]); $i < $l; ++$i) { $result .= '\\' . str_pad( strtoupper(dechex(ord($chars[0][$i]))), 2, '0', STR_PAD_LEFT ); } return $result; } }