phpnuxbill/system/autoload/PEAR2/Net/RouterOS/Client.php
2017-03-11 02:51:06 +07:00

819 lines
28 KiB
PHP

<?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.0b5
* @link http://pear2.php.net/PEAR2_Net_RouterOS
*/
/**
* The namespace declaration.
*/
namespace PEAR2\Net\RouterOS;
/**
* Refers to transmitter direction constants.
*/
use PEAR2\Net\Transmitter\Stream as S;
/**
* Refers to the cryptography constants.
*/
use PEAR2\Net\Transmitter\NetworkStream as N;
/**
* Catches arbitrary exceptions at some points.
*/
use Exception as E;
/**
* A RouterOS client.
*
* Provides functionality for easily communicating with a RouterOS host.
*
* @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 Client
{
/**
* Used in {@link static::isRequestActive()} to limit search only to
* requests that have a callback.
*/
const FILTER_CALLBACK = 1;
/**
* Used in {@link static::isRequestActive()} to limit search only to
* requests that use the buffer.
*/
const FILTER_BUFFER = 2;
/**
* Used in {@link static::isRequestActive()} to indicate no limit in search.
*/
const FILTER_ALL = 3;
/**
* @var Communicator The communicator for this client.
*/
protected $com;
/**
* @var int The number of currently pending requests.
*/
protected $pendingRequestsCount = 0;
/**
* @var array An array of responses that have not yet been extracted or
* passed to a callback. Key is the tag of the request, and the value
* is an array of associated responses.
*/
protected $responseBuffer = array();
/**
* @var array An array of callbacks to be executed as responses come.
* Key is the tag of the request, and the value is the callback for it.
*/
protected $callbacks = array();
/**
* @var Registry A registry for the operations. Particularly helpful at
* persistent connections.
*/
protected $registry = null;
/**
* @var bool Whether to stream future responses.
*/
private $_streamingResponses = false;
/**
* Creates a new instance of a RouterOS API client.
*
* Creates a new instance of a RouterOS API client with the specified
* settings.
*
* @param string $host Hostname (IP or domain) of the RouterOS server.
* @param string $username The RouterOS username.
* @param string $password The RouterOS password.
* @param int|null $port The port on which the RouterOS server provides
* the API service. You can also specify NULL, in which case the port
* will automatically be chosen between 8728 and 8729, depending on the
* value of $crypto.
* @param bool $persist Whether or not the connection should be a
* persistent one.
* @param float $timeout The timeout for the connection.
* @param string $crypto The encryption for this connection. Must be one
* of the PEAR2\Net\Transmitter\NetworkStream::CRYPTO_* constants. Off
* by default. RouterOS currently supports only TLS, but the setting is
* provided in this fashion for forward compatibility's sake. And for
* the sake of simplicity, if you specify an encryption, don't specify a
* context and your default context uses the value "DEFAULT" for
* ciphers, "ADH" will be automatically added to the list of ciphers.
* @param resource $context A context for the socket.
*
* @see sendSync()
* @see sendAsync()
*/
public function __construct(
$host,
$username,
$password = '',
$port = 8728,
$persist = false,
$timeout = null,
$crypto = N::CRYPTO_OFF,
$context = null
) {
$this->com = new Communicator(
$host,
$port,
$persist,
$timeout,
$username . '/' . $password,
$crypto,
$context
);
$timeout = null == $timeout
? ini_get('default_socket_timeout')
: (int) $timeout;
//Login the user if necessary
if ((!$persist
|| !($old = $this->com->getTransmitter()->lock(S::DIRECTION_ALL)))
&& $this->com->getTransmitter()->isFresh()
) {
if (!static::login($this->com, $username, $password, $timeout)) {
$this->com->close();
throw new DataFlowException(
'Invalid username or password supplied.',
DataFlowException::CODE_INVALID_CREDENTIALS
);
}
}
if (isset($old)) {
$this->com->getTransmitter()->lock($old, true);
}
if ($persist) {
$this->registry = new Registry("{$host}:{$port}/{$username}");
}
}
/**
* A shorthand gateway.
*
* This is a magic PHP method that allows you to call the object as a
* function. Depending on the argument given, one of the other functions in
* the class is invoked and its returned value is returned by this function.
*
* @param mixed $arg Value can be either a {@link Request} to send, which
* would be sent asynchoniously if it has a tag, and synchroniously if
* not, a number to loop with or NULL to complete all pending requests.
* Any other value is converted to string and treated as the tag of a
* request to complete.
*
* @return mixed Whatever the long form function would have returned.
*/
public function __invoke($arg = null)
{
if (is_int($arg) || is_double($arg)) {
return $this->loop($arg);
} elseif ($arg instanceof Request) {
return '' == $arg->getTag() ? $this->sendSync($arg)
: $this->sendAsync($arg);
} elseif (null === $arg) {
return $this->completeRequest();
}
return $this->completeRequest((string) $arg);
}
/**
* Login to a RouterOS connection.
*
* @param Communicator $com The communicator to attempt to login to.
* @param string $username The RouterOS username.
* @param string $password The RouterOS password.
* @param int|null $timeout The time to wait for each response. NULL
* waits indefinetly.
*
* @return bool TRUE on success, FALSE on failure.
*/
public static function login(
Communicator $com,
$username,
$password = '',
$timeout = null
) {
if (null !== ($remoteCharset = $com->getCharset($com::CHARSET_REMOTE))
&& null !== ($localCharset = $com->getCharset($com::CHARSET_LOCAL))
) {
$password = iconv(
$localCharset,
$remoteCharset . '//IGNORE//TRANSLIT',
$password
);
}
$old = null;
try {
if ($com->getTransmitter()->isPersistent()) {
$old = $com->getTransmitter()->lock(S::DIRECTION_ALL);
$result = self::_login($com, $username, $password, $timeout);
$com->getTransmitter()->lock($old, true);
return $result;
}
return self::_login($com, $username, $password, $timeout);
} catch (E $e) {
if ($com->getTransmitter()->isPersistent() && null !== $old) {
$com->getTransmitter()->lock($old, true);
}
throw ($e instanceof NotSupportedException
|| $e instanceof UnexpectedValueException
|| !$com->getTransmitter()->isDataAwaiting()) ? new SocketException(
'This is not a compatible RouterOS service',
SocketException::CODE_SERVICE_INCOMPATIBLE,
$e
) : $e;
}
}
/**
* Login to a RouterOS connection.
*
* This is the actual login procedure, applied regardless of persistence and
* charset settings.
*
* @param Communicator $com The communicator to attempt to login to.
* @param string $username The RouterOS username.
* @param string $password The RouterOS password. Potentially parsed
* already by iconv.
* @param int|null $timeout The time to wait for each response. NULL
* waits indefinetly.
*
* @return bool TRUE on success, FALSE on failure.
*/
private static function _login(
Communicator $com,
$username,
$password = '',
$timeout = null
) {
$request = new Request('/login');
$request->send($com);
$response = new Response($com, false, $timeout);
$request->setArgument('name', $username);
$request->setArgument(
'response',
'00' . md5(
chr(0) . $password
. pack('H*', $response->getProperty('ret'))
)
);
$request->send($com);
$response = new Response($com, false, $timeout);
return $response->getType() === Response::TYPE_FINAL
&& null === $response->getProperty('ret');
}
/**
* Sets the charset(s) for this connection.
*
* Sets the charset(s) for this connection. The specified charset(s) will be
* used for all future requests and responses. When sending,
* {@link Communicator::CHARSET_LOCAL} is converted to
* {@link Communicator::CHARSET_REMOTE}, and when receiving,
* {@link Communicator::CHARSET_REMOTE} is converted to
* {@link Communicator::CHARSET_LOCAL}. Setting NULL to either charset will
* disable charset convertion, and data will be both sent and received "as
* is".
*
* @param mixed $charset The charset to set. If $charsetType is
* {@link Communicator::CHARSET_ALL}, you can supply either a string to
* use for all charsets, or an array with the charset types as keys, and
* the charsets as values.
* @param int $charsetType Which charset to set. Valid values are the
* Communicator::CHARSET_* constants. Any other value is treated as
* {@link Communicator::CHARSET_ALL}.
*
* @return string|array The old charset. If $charsetType is
* {@link Communicator::CHARSET_ALL}, the old values will be returned as
* an array with the types as keys, and charsets as values.
* @see Communicator::setDefaultCharset()
*/
public function setCharset(
$charset,
$charsetType = Communicator::CHARSET_ALL
) {
return $this->com->setCharset($charset, $charsetType);
}
/**
* Gets the charset(s) for this connection.
*
* @param int $charsetType Which charset to get. Valid values are the
* Communicator::CHARSET_* constants. Any other value is treated as
* {@link Communicator::CHARSET_ALL}.
*
* @return string|array The current charset. If $charsetType is
* {@link Communicator::CHARSET_ALL}, the current values will be
* returned as an array with the types as keys, and charsets as values.
* @see setCharset()
*/
public function getCharset($charsetType)
{
return $this->com->getCharset($charsetType);
}
/**
* Sends a request and waits for responses.
*
* @param Request $request The request to send.
* @param callback $callback Optional. A function that is to be executed
* when new responses for this request are available. The callback takes
* two parameters. The {@link Response} object as the first, and the
* {@link Client} object as the second one. If the function returns
* TRUE, the request is canceled. Note that the callback may be executed
* one last time after that with a response that notifies about the
* canceling.
*
* @return $this The client object.
* @see completeRequest()
* @see loop()
* @see cancelRequest()
*/
public function sendAsync(Request $request, $callback = null)
{
//Error checking
$tag = $request->getTag();
if ('' == $tag) {
throw new DataFlowException(
'Asynchonous commands must have a tag.',
DataFlowException::CODE_TAG_REQUIRED
);
}
if ($this->isRequestActive($tag)) {
throw new DataFlowException(
'There must not be multiple active requests sharing a tag.',
DataFlowException::CODE_TAG_UNIQUE
);
}
if (null !== $callback && !is_callable($callback, true)) {
throw new UnexpectedValueException(
'Invalid callback provided.',
UnexpectedValueException::CODE_CALLBACK_INVALID
);
}
$this->send($request);
if (null === $callback) {
//Register the request at the buffer
$this->responseBuffer[$tag] = array();
} else {
//Prepare the callback
$this->callbacks[$tag] = $callback;
}
return $this;
}
/**
* Checks if a request is active.
*
* Checks if a request is active. A request is considered active if it's a
* pending request and/or has responses that are not yet extracted.
*
* @param string $tag The tag of the request to look for.
* @param int $filter One of the FILTER_* consntants. Limits the search
* to the specified places.
*
* @return bool TRUE if the request is active, FALSE otherwise.
* @see getPendingRequestsCount()
* @see completeRequest()
*/
public function isRequestActive($tag, $filter = self::FILTER_ALL)
{
$result = 0;
if ($filter & self::FILTER_CALLBACK) {
$result |= (int) array_key_exists($tag, $this->callbacks);
}
if ($filter & self::FILTER_BUFFER) {
$result |= (int) array_key_exists($tag, $this->responseBuffer);
}
return 0 !== $result;
}
/**
* Sends a request and gets the full response.
*
* @param Request $request The request to send.
*
* @return ResponseCollection The received responses as a collection.
* @see sendAsync()
* @see close()
*/
public function sendSync(Request $request)
{
$tag = $request->getTag();
if ('' == $tag) {
$this->send($request);
} else {
$this->sendAsync($request);
}
return $this->completeRequest($tag);
}
/**
* Completes a specified request.
*
* Starts an event loop for the RouterOS callbacks and finishes when a
* specified request is completed.
*
* @param string $tag The tag of the request to complete. Setting NULL
* completes all requests.
*
* @return ResponseCollection A collection of {@link Response} objects that
* haven't been passed to a callback function or previously extracted
* with {@link static::extractNewResponses()}. Returns an empty
* collection when $tag is set to NULL (responses can still be
* extracted).
*/
public function completeRequest($tag = null)
{
$hasNoTag = '' == $tag;
$result = $hasNoTag ? array()
: $this->extractNewResponses($tag)->toArray();
while ((!$hasNoTag && $this->isRequestActive($tag))
|| ($hasNoTag && 0 !== $this->getPendingRequestsCount())
) {
$newReply = $this->dispatchNextResponse(null);
if ($newReply->getTag() === $tag) {
if ($hasNoTag) {
$result[] = $newReply;
}
if ($newReply->getType() === Response::TYPE_FINAL) {
if (!$hasNoTag) {
$result = array_merge(
$result,
$this->isRequestActive($tag)
? $this->extractNewResponses($tag)->toArray()
: array()
);
}
break;
}
}
}
return new ResponseCollection($result);
}
/**
* Extracts responses for a request.
*
* Gets all new responses for a request that haven't been passed to a
* callback and clears the buffer from them.
*
* @param string $tag The tag of the request to extract new responses for.
* Specifying NULL with extract new responses for all requests.
*
* @return ResponseCollection A collection of {@link Response} objects for
* the specified request.
* @see loop()
*/
public function extractNewResponses($tag = null)
{
if (null === $tag) {
$result = array();
foreach (array_keys($this->responseBuffer) as $tag) {
$result = array_merge(
$result,
$this->extractNewResponses($tag)->toArray()
);
}
return new ResponseCollection($result);
} elseif ($this->isRequestActive($tag, self::FILTER_CALLBACK)) {
return new ResponseCollection(array());
} elseif ($this->isRequestActive($tag, self::FILTER_BUFFER)) {
$result = $this->responseBuffer[$tag];
if (!empty($result)) {
if (end($result)->getType() === Response::TYPE_FINAL) {
unset($this->responseBuffer[$tag]);
} else {
$this->responseBuffer[$tag] = array();
}
}
return new ResponseCollection($result);
} else {
throw new DataFlowException(
'No such request, or the request has already finished.',
DataFlowException::CODE_UNKNOWN_REQUEST
);
}
}
/**
* Starts an event loop for the RouterOS callbacks.
*
* Starts an event loop for the RouterOS callbacks and finishes when there
* are no more pending requests or when a specified timeout has passed
* (whichever comes first).
*
* @param int $sTimeout Timeout for the loop. If NULL, there is no time
* limit.
* @param int $usTimeout Microseconds to add to the time limit.
*
* @return bool TRUE when there are any more pending requests, FALSE
* otherwise.
* @see extractNewResponses()
* @see getPendingRequestsCount()
*/
public function loop($sTimeout = null, $usTimeout = 0)
{
try {
if (null === $sTimeout) {
while ($this->getPendingRequestsCount() !== 0) {
$this->dispatchNextResponse(null);
}
} else {
list($usStart, $sStart) = explode(' ', microtime());
while ($this->getPendingRequestsCount() !== 0
&& ($sTimeout >= 0 || $usTimeout >= 0)
) {
$this->dispatchNextResponse($sTimeout, $usTimeout);
list($usEnd, $sEnd) = explode(' ', microtime());
$sTimeout -= $sEnd - $sStart;
$usTimeout -= $usEnd - $usStart;
if ($usTimeout <= 0) {
if ($sTimeout > 0) {
$usTimeout = 1000000 + $usTimeout;
$sTimeout--;
}
}
$sStart = $sEnd;
$usStart = $usEnd;
}
}
} catch (SocketException $e) {
if ($e->getCode() !== SocketException::CODE_NO_DATA) {
// @codeCoverageIgnoreStart
// It's impossible to reliably cause any other SocketException.
// This line is only here in case the unthinkable happens:
// The connection terminates just after it was supposedly
// about to send back some data.
throw $e;
// @codeCoverageIgnoreEnd
}
}
return $this->getPendingRequestsCount() !== 0;
}
/**
* Gets the number of pending requests.
*
* @return int The number of pending requests.
* @see isRequestActive()
*/
public function getPendingRequestsCount()
{
return $this->pendingRequestsCount;
}
/**
* Cancels a request.
*
* Cancels an active request. Using this function in favor of a plain call
* to the "/cancel" command is highly reccomended, as it also updates the
* counter of pending requests properly. Note that canceling a request also
* removes any responses for it that were not previously extracted with
* {@link static::extractNewResponses()}.
*
* @param string $tag Tag of the request to cancel. Setting NULL will cancel
* all requests.
*
* @return $this The client object.
* @see sendAsync()
* @see close()
*/
public function cancelRequest($tag = null)
{
$cancelRequest = new Request('/cancel');
$hasTag = !('' == $tag);
$hasReg = null !== $this->registry;
if ($hasReg && !$hasTag) {
$tags = array_merge(
array_keys($this->responseBuffer),
array_keys($this->callbacks)
);
$this->registry->setTaglessMode(true);
foreach ($tags as $t) {
$cancelRequest->setArgument(
'tag',
$this->registry->getOwnershipTag() . $t
);
$this->sendSync($cancelRequest);
}
$this->registry->setTaglessMode(false);
} else {
if ($hasTag) {
if ($this->isRequestActive($tag)) {
if ($hasReg) {
$this->registry->setTaglessMode(true);
$cancelRequest->setArgument(
'tag',
$this->registry->getOwnershipTag() . $tag
);
} else {
$cancelRequest->setArgument('tag', $tag);
}
} else {
throw new DataFlowException(
'No such request. Canceling aborted.',
DataFlowException::CODE_CANCEL_FAIL
);
}
}
$this->sendSync($cancelRequest);
if ($hasReg) {
$this->registry->setTaglessMode(false);
}
}
if ($hasTag) {
if ($this->isRequestActive($tag, self::FILTER_BUFFER)) {
$this->responseBuffer[$tag] = $this->completeRequest($tag);
} else {
$this->completeRequest($tag);
}
} else {
$this->loop();
}
return $this;
}
/**
* Sets response streaming setting.
*
* Sets whether future responses are streamed. If responses are streamed,
* the argument values are returned as streams instead of strings. This is
* particularly useful if you expect a response that may contain one or more
* very large words.
*
* @param bool $streamingResponses Whether to stream future responses.
*
* @return bool The previous value of the setting.
* @see isStreamingResponses()
*/
public function setStreamingResponses($streamingResponses)
{
$oldValue = $this->_streamingResponses;
$this->_streamingResponses = (bool) $streamingResponses;
return $oldValue;
}
/**
* Gets response streaming setting.
*
* Gets whether future responses are streamed.
*
* @return bool The value of the setting.
* @see setStreamingResponses()
*/
public function isStreamingResponses()
{
return $this->_streamingResponses;
}
/**
* Closes the opened connection, even if it is a persistent one.
*
* Closes the opened connection, even if it is a persistent one. Note that
* {@link static::extractNewResponses()} can still be used to extract
* responses collected prior to the closing.
*
* @return bool TRUE on success, FALSE on failure.
*/
public function close()
{
$result = true;
/*
* The check below is done because for some unknown reason
* (either a PHP or a RouterOS bug) calling "/quit" on an encrypted
* connection makes one end hang.
*
* Since encrypted connections only appeared in RouterOS 6.1, and
* the "/quit" call is needed for all <6.0 versions, problems due
* to its absence should be limited to some earlier 6.* versions
* on some RouterBOARD devices.
*/
if ($this->com->getTransmitter()->getCrypto() === N::CRYPTO_OFF) {
if (null !== $this->registry) {
$this->registry->setTaglessMode(true);
}
try {
$response = $this->sendSync(new Request('/quit'));
$result = $response[0]->getType() === Response::TYPE_FATAL;
} catch (SocketException $e) {
$result
= $e->getCode() === SocketException::CODE_REQUEST_SEND_FAIL;
} catch (E $e) {
//Ignore unknown errors.
}
if (null !== $this->registry) {
$this->registry->setTaglessMode(false);
}
}
$result = $result && $this->com->close();
$this->callbacks = array();
$this->pendingRequestsCount = 0;
return $result;
}
/**
* Closes the connection, unless it's a persistent one.
*/
public function __destruct()
{
if ($this->com->getTransmitter()->isPersistent()) {
if (0 !== $this->pendingRequestsCount) {
$this->cancelRequest();
}
} else {
$this->close();
}
}
/**
* Sends a request to RouterOS.
*
* @param Request $request The request to send.
*
* @return $this The client object.
* @see sendSync()
* @see sendAsync()
*/
protected function send(Request $request)
{
$request->send($this->com, $this->registry);
$this->pendingRequestsCount++;
return $this;
}
/**
* Dispatches the next response in queue.
*
* Dispatches the next response in queue, i.e. it executes the associated
* callback if there is one, or places the response in the response buffer.
*
* @param int $sTimeout If a response is not immediatly available, wait
* this many seconds. If NULL, wait indefinetly.
* @param int $usTimeout Microseconds to add to the waiting time.
*
* @throws SocketException When there's no response within the time limit.
* @return Response The dispatched response.
*/
protected function dispatchNextResponse($sTimeout = 0, $usTimeout = 0)
{
$response = new Response(
$this->com,
$this->_streamingResponses,
$sTimeout,
$usTimeout,
$this->registry
);
if ($response->getType() === Response::TYPE_FATAL) {
$this->pendingRequestsCount = 0;
$this->com->close();
return $response;
}
$tag = $response->getTag();
$isLastForRequest = $response->getType() === Response::TYPE_FINAL;
if ($isLastForRequest) {
$this->pendingRequestsCount--;
}
if ('' != $tag) {
if ($this->isRequestActive($tag, self::FILTER_CALLBACK)) {
if ($this->callbacks[$tag]($response, $this)) {
$this->cancelRequest($tag);
} elseif ($isLastForRequest) {
unset($this->callbacks[$tag]);
}
} else {
$this->responseBuffer[$tag][] = $response;
}
}
return $response;
}
}