From a5ab6f29466bdac5c7e0ee6c8a193e4bffd8f06d Mon Sep 17 00:00:00 2001 From: dapphp Date: Wed, 24 Jun 2015 16:09:52 -0700 Subject: [PATCH] Initial commit --- ControlClient.php | 1089 ++++++++++++++++++++ DirectoryClient.php | 272 +++++ LICENSE | 28 + Parser.php | 636 ++++++++++++ ProtocolError.php | 21 + ProtocolReply.php | 273 +++++ README.md | 127 +++ RouterDescriptor.php | 225 ++++ TorDNSEL.php | 394 +++++++ examples/TorDNSEL.php | 49 + examples/common.php | 46 + examples/dc_GetAllDescriptors-simple.php | 31 + examples/dc_GetServerDescriptor-simple.php | 26 + examples/tc_GetConf.php | 62 ++ examples/tc_GetInfo.php | 91 ++ examples/tc_SendData.php | 72 ++ 16 files changed, 3442 insertions(+) create mode 100644 ControlClient.php create mode 100644 DirectoryClient.php create mode 100644 LICENSE create mode 100644 Parser.php create mode 100644 ProtocolError.php create mode 100644 ProtocolReply.php create mode 100644 README.md create mode 100644 RouterDescriptor.php create mode 100644 TorDNSEL.php create mode 100644 examples/TorDNSEL.php create mode 100644 examples/common.php create mode 100644 examples/dc_GetAllDescriptors-simple.php create mode 100644 examples/dc_GetServerDescriptor-simple.php create mode 100644 examples/tc_GetConf.php create mode 100644 examples/tc_GetInfo.php create mode 100644 examples/tc_SendData.php diff --git a/ControlClient.php b/ControlClient.php new file mode 100644 index 0000000..d5d5118 --- /dev/null +++ b/ControlClient.php @@ -0,0 +1,1089 @@ + + * @version 1.0 (June 21, 2015) + * + */ + +namespace Dapphp\TorUtils; + +require_once 'Parser.php'; +require_once 'ProtocolReply.php'; +require_once 'RouterDescriptor.php'; +require_once 'ProtocolError.php'; + +use Dapphp\TorUtils\ProtocolReply; +use Dapphp\TorUtils\ProtocolError; +use Dapphp\TorUtils\RouterDescriptor; + +/** + * Tor ControlClient class + * + * A class for interacting with a Tor node using Tor's control protocol. + * + * @version 1.0 + * @author Drew Phillips + * + */ +class ControlClient +{ + const GETINFO_VERSION = 'version'; + const GETINFO_CFGFILE = 'config-file'; + const GETINFO_DESCRIPTOR_ALL = 'desc/all'; + const GETINFO_DESCRIPTOR_ID = 'desc/id/%s'; + const GETINFO_DESCRIPTOR_NAME = 'desc/name/%s'; + const GETINFO_UDESCRIPTOR_ID = 'md/id/%s'; + const GETINFO_UDESCRIPTOR_NAME = 'md/name/%s'; + const GETINFO_DORMANT = 'dormant'; + const GETINFO_NETSTATUS_ALL = 'ns/all'; + const GETINFO_NETSTATUS_ID = 'ns/id/%s'; + const GETINFO_NETSTATUS_NAME = 'ns/name/%s'; + const GETINFO_DIRSTATUS_ALL = 'dir/server/all'; + const GETINFO_ADDRESS = 'address'; + const GETINFO_FINGERPRINT = 'fingerprint'; + const GETINFO_TRAFFICREAD = 'traffic/read'; + const GETINFO_TRAFFICWRITTEN = 'traffic/written'; + const GETINFO_ENTRY_GUARDS = 'entry-guards'; + + const SIGNAL_RELOAD = 'RELOAD'; + const SIGNAL_SHUTDOWN = 'SHUTDOWN'; + const SIGNAL_DUMP = 'DUMP'; + const SIGNAL_DEBUG = 'DEBUG'; + const SIGNAL_HALT = 'HALT'; + const SIGNAL_NEWNYM = 'NEWNYM'; + const SIGNAL_CLEARDNSCACHE = 'CLEARDNSCACHE'; + const SIGNAL_HEARTBEAT = 'HEARTBEAT'; + + const AUTH_SAFECOOKIE_SERVER_TO_CONTROLLER = 'Tor safe cookie authentication server-to-controller hash'; + const AUTH_SAFECOOKIE_CONTROLLER_TO_SERVER = 'Tor safe cookie authentication controller-to-server hash'; + + private $_host; + private $_port; + private $_debug; + private $_debugFp; + private $_sock; + private $_parser; + private $_eventCallback; + + /** + * ControlClient constructor. + * + * The ControlClient connects to and communicates directly with a Tor node + * over the Tor Control protocol. + */ + public function __construct() + { + $this->_host = '127.0.0.1'; + $this->_port = 9051; + $this->_timeout = 30; + $this->_debug = false; + $this->_debugFp = fopen('php://stderr', 'w'); + $this->_parser = new Parser(); + $this->_eventCallback = null; + } + + /** + * Establish a connection to the controller + * + * @param string $host The IP or hostname of the controller + * @param string $port The port number (default 9051) + * @throws \Exception Throws \Exception if the connection fails + * @return \Dapphp\TorUtils\ControlClient + */ + public function connect($host = null, $port = null) + { + if (is_null($host)) $host = $this->_host; + if (is_null($port)) $port = $this->_port; + + $this->_sock = fsockopen($host, $port, $errno, $errstr, $this->_timeout); + + if (!$this->_sock) { + throw new \Exception( + sprintf("Failed to connect to host %s on port %d. Error: %d - %s", $this->_host, $this->_port, $errno, $errstr) + ); + } + + return $this; + } + + /** + * Close the control connection + * + * @return boolean true on success, false if an error occurred + */ + public function quit() + { + if (!$this->_sock) { + return true; + } + + $this->sendData('QUIT'); + $reply = $this->readReply(); + + if ($reply->isPositiveReply()) { + fclose($this->_sock); + return true; + } else { + fclose($this->_sock); + return false; + } + } + + /** + * Authenticate with the controller. + * + * If the authentication method NONE is supported, it will be used first + * otherwise the SAFECOOKIE method will be used (if available), and finally + * if a password is provided the HASHEDPASSWORD authentication method will + * be used. + * + * @param string $password Optional password used for authentication + * @throws \Exception Throws exception if no suitable authentication methods are available + * @throws \Dapphp\TorUtils\ProtocolError Throws ProtocolError if authentication failed (incorrect password or cookie file) + */ + public function authenticate($password = null) + { + $pinfo = $this->_getProtocolInfo(); + + if (in_array('NONE', $pinfo['methods'])) { + $this->authenticateNone(); + } else if ($password !== null && in_array('HASHEDPASSWORD', $pinfo['methods'])) { + $this->authenticatePassword($password); + } else if (in_array('SAFECOOKIE', $pinfo['methods'])) { + $this->authenticateSafecookie($pinfo['cookiefile']); + } else { + throw new \Exception('No suitable authentication methods available'); + } + } + + /** + * Send data or a command to the controller + * + * @param string $data The command and data to send + * @return int the number of bytes sent to the controller + */ + public function sendData($data) + { + $data = $data . "\r\n"; + + if ($this->_debug) $this->_debugOut($data, '>>> '); + + $sent = fwrite($this->_sock, $data); + + return $sent; + } + + /** + * Read a complete reply from the controller. + * + * This method will process asynchronous events, call the user callback for + * async events (if any) and then continue to attempt to read a reply from + * the controller if data is available. + * + * This method blocks if there is nothing to be read from the controller. + * + * @param $cmd The name of the previous command sent to the controller + * @return \Dapphp\TorUtils\ProtocolReply ProtocolReply object containing the response from the controller + */ + public function readReply($cmd = null) + { + $reply = new ProtocolReply($cmd); + $first = true; + + while (true) { + $data = $this->_recvData(); + if ($data === false) break; + + if ($this->_isEventReplyLine($data)) { + // TODO: invoke a callback or process the event and store it somewhere + if ($first) { + $first = false; + $evreply = new ProtocolReply(); + } + $evreply->appendReplyLine($data); + } else if (trim($data) == '.') { + $end = $this->_recvData(); + if (!$this->_isEndReplyLine($end)) { + throw new ProtocolError('Last read "." line - expected EndReplyLine but got "' . trim($end) . '"'); + } + if (!isset($evreply)) { + break; + } else { + $data = $end; + // run code at the end of the loop to process evreply + } + } else { + if (isset($evreply)) { + $evreply->appendReplyLine($data); + } else { + $reply->appendReplyLine($data); + } + } + + if ($this->_isEndReplyLine($data)) { + if (isset($evreply)) { + $this->_asyncEventHandler($evreply); + unset($evreply); + $first = true; + } else { + break; + } + } + } + + return $reply; + } + + /** + * Send the GETINFO command to the controller to retrieve information + * from the controller. Use the $keyword parameter to pass a valid + * option to GETINFO or use a ControlClient::GETINFO_* constant. + * + * @param string $keyword The info keyword + * @param string $params Additional parameters to send if the keyword requires it + * @throws \Exception If too few parameters are passed for the $keyword used + * @return \Dapphp\TorUtils\ProtocolReply Protocol reply from the controller + */ + public function getInfo($keyword, $params = null) + { + if ($params === null) { + $cmd = $keyword; + } else { + $args = func_get_args(); + array_shift($args); + + $cmd = @vsprintf($keyword, $args); + if ($cmd === false) { + throw new \Exception('Too few params passed to getInfo command'); + } + } + + $this->sendData('GETINFO ' . $cmd); + $reply = $this->readReply($cmd); + + return $reply; + } + + /** + * Get information about a descriptor from the controller. + * + * This sends a GETINFO command to the controller and parses the response + * returning a single descriptor or array of descriptors depending on + * the $descriptorNameOfID parameter + * + * @param string $descriptorNameOrID If null, get info on ALL descriptors, otherwise gets information based on the fingerprint or nickname given + * @throws \Exception If $descriptorNameOrID is not a valid finterprint or nickname + * @throws ProtocolError If no such descriptor was found or other protocol error + * @return \Dapphp\TorUtils\RouterDescriptor|array Returns array if $descriptorNameOrID is null, otherwise returns a single RouterDescriptor object + */ + public function getInfoDescriptor($descriptorNameOrID = null) + { + if (is_null($descriptorNameOrID)) { + $cmd = self::GETINFO_DESCRIPTOR_ALL; + } else if ($this->_isFingerprint($descriptorNameOrID)) { + $cmd = self::GETINFO_DESCRIPTOR_ID; + if ($descriptorNameOrID[0] != '$') $descriptorNameOrID = '$' . $descriptorNameOrID; + } else if ($this->_isNickname($descriptorNameOrID)) { + $cmd = self::GETINFO_DESCRIPTOR_NAME; + } else { + throw new \Exception(sprintf('"%s" is not a valid router fingerprint or nickname', $descriptorNameOrID)); + } + + $reply = $this->getInfo($cmd, $descriptorNameOrID); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } + + $descriptors = $this->_parser->parseDirectoryStatus($reply); + + if (!is_null($descriptorNameOrID)) { + return array_shift($descriptors); + } else { + return $descriptors; + } + } + + /** + * Get information about a descriptor from the controller using the + * microdescriptor format. Use only if controller requires it. + * + * @see ControlClient::getInfoDescriptor() + * @param string $descriptorNameOrID + * @throws \Exception If $descriptorNameOrID is not a valid finterprint or nickname + * @throws ProtocolError + * @return mixed|unknown + */ + public function getInfoMicroDescriptor($descriptorNameOrID = null) + { + if ($this->_isFingerprint($descriptorNameOrID)) { + $cmd = self::GETINFO_UDESCRIPTOR_ID; + if ($descriptorNameOrID[0] != '$') $descriptorNameOrID = '$' . $descriptorNameOrID; + } else if ($this->_isNickname($descriptorNameOrID)) { + $cmd = self::GETINFO_UDESCRIPTOR_NAME; + } else { + throw new \Exception(sprintf('"%s" is not a valid router fingerprint or nickname', $descriptorNameOrID)); + } + + $reply = $this->getInfo($cmd, $descriptorNameOrID); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } + + $descriptors = $this->_parser->parseDirectoryStatus($reply); + + if (!is_null($descriptorNameOrID)) { + return array_shift($descriptors); + } else { + return $descriptors; + } + } + + /** + * Uses the GETINFO command to fetch network status about a node or all + * nodes on the network. + * + * @param string $descriptorNameOrID Fingerprint, nickname, or null for all descriptors + * @throws \Exception If $descriptorNameOrID is not a valid finterprint or nickname + * @throws ProtocolError If no such descriptor was found or other protocol error + * @return RouterDescriptor|array Returns array if $descriptorNameOrID is null, otherwise returns a single RouterDescriptor object + */ + public function getInfoDirectoryStatus($descriptorNameOrID = null) + { + if (is_null($descriptorNameOrID)) { + $cmd = self::GETINFO_NETSTATUS_ALL; + } else if ($this->_isFingerprint($descriptorNameOrID)) { + $cmd = self::GETINFO_NETSTATUS_ID; + if ($descriptorNameOrID[0] != '$') $descriptorNameOrID = '$' . $descriptorNameOrID; + } else if ($this->_isNickname($descriptorNameOrID)) { + $cmd = self::GETINFO_NETSTATUS_NAME; + } else { + throw new \Exception(sprintf('"%s" is not a valid router fingerprint or nickname', $descriptorNameOrID)); + } + + $reply = $this->getInfo($cmd, $descriptorNameOrID); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } + + $descriptors = $this->_parser->parseRouterStatus($reply); + + if (!is_null($descriptorNameOrID)) { + return array_shift($descriptors); + } else { + return $descriptors; + } + } + + /** + * Return the best guess of Tor's external IP address + * + * @throws ProtocolError If address could not be determined + * @return string Tor's external IP address + */ + public function getInfoAddress() + { + $cmd = self::GETINFO_ADDRESS; + $reply = $this->getInfo($cmd); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } else { + return $reply[0]; + } + } + + /** + * The contents of the fingerprint file that Tor writes as a relay + * + * @throws ProtocolError If we are not currently a relay + * @return string Fingerprint of relay + */ + public function getInfoFingerprint() + { + $cmd = self::GETINFO_FINGERPRINT; + $reply = $this->getInfo($cmd); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } else { + return $reply[0]; + } + } + + /** + * Gets the version of Tor being run by the controller + * + * @throws ProtocolError + * @return string The Tor version and platform in use + */ + public function getVersion() + { + $reply = $this->getInfo(self::GETINFO_VERSION); + + if ($reply->isPositiveReply()) { + return $reply[0]; + } else { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } + } + + /** + * Gets the total bytes read (downloaded) + * + * @return string The number of bytes read (downloaded) + */ + public function getInfoTrafficRead() + { + $cmd = self::GETINFO_TRAFFICREAD; + $reply = $this->getInfo($cmd); + + return $reply[0]; + } + + /** + * Gets the total bytes written (uploaded) + * + * @return string The number of bytes written + */ + public function getInfoTrafficWritten() + { + $cmd = self::GETINFO_TRAFFICWRITTEN; + $reply = $this->getInfo($cmd); + + return $reply[0]; + } + + /** + * Gets the current configuration values of one or more torrc option + * + * @param string $keywords Space separated list of keywords to get config values for + * @throws ProtocolError If one or more options was not recognized + * @return multitype:array Array of config values keyed by the option name + */ + public function getConf($keywords) + { + $cmd = 'GETCONF'; + $this->sendData(sprintf('%s %s', $cmd, $keywords)); + $reply = $this->readReply($cmd); + + if (!$reply->isPositiveReply()) { + $message = implode('; ', $reply->getReplyLines()); + throw new ProtocolError($message, $reply->getStatusCode()); + } + + $values = array(); + + foreach($reply as $keyword => $value) { + // $value will look like '=value' and $keyword will be a string + // OR $keyword will be numeric and value will look like 'keyword=value' + if (is_int($keyword)) { + list($keyword,$value) = explode('=', $value); + } else { + $value = ltrim($value, '='); + } + + $values[$keyword] = $value; + } + + return $values; + } + + /** + * Set one or more configuration values for Tor + * + * @param array $config Array of torrc values keyed by the option name + * @throws ProtocolError If one or more options was not recognized or could not be set + * @return \Dapphp\TorUtils\ControlClient + */ + public function setConf(array $config) + { + $cmd = 'SETCONF'; + $params = ''; + + foreach($config as $keyword => $value) { + if (strpos($value, ' ') !== false) { + $value = trim($value, '"\''); + $value = '"' . $value . '"'; + } + + $params .= ' ' .$keyword . '=' . $value; + } + + $this->sendData(sprintf('%s%s', $cmd, $params)); + $reply = $this->readReply($cmd); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } + + return $this; + } + + /** + * Sends the SIGNAL command to signal the controller to react based on the + * signal sent. + * + * @param string $signal The signal or a ControlClient::SIGNAL_* constant + * @throws ProtocolError If the signal is not recognized + * @return \Dapphp\TorUtils\ControlClient + */ + public function signal($signal) + { + $cmd = 'SIGNAL'; + $this->sendData(sprintf('%s %s', $cmd, $signal)); + $reply = $this->readReply($cmd); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } + + return $this; + } + + /** + * Send the SETEVENTS command to the controller to subscribe to one or more + * asynchronous events. + * + * @param array|string $events An event or array of events to subscribe to + * @throws \Exception If $events was not a string or array + * @throws ProtocolError If one or more events was not recognized (no events will be set) + * @return \Dapphp\TorUtils\ControlClient + */ + public function setEvents($events) + { + if (is_array($events)) { + $events = implode(' ', $events); + } else if (!is_string($events)) { + throw new \Exception('$events must be a string or array; ' . gettype($events) . ' given'); + } + + $cmd = 'SETEVENTS'; + $this->sendData(sprintf('%s %s', $cmd, $events)); + $reply = $this->readReply($cmd); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } + + return $this; + } + + /** + * Instruct the controller to resolve a hostname using DNS over Tor. The + * name resolution is done in the background. Client can see resolved + * addresses by subscribing to the ADDRMAP event + * + * @param array|string $address The hostname(s) to resolve + * @throws \Exception + * @throws ProtocolError Invalid address given + * @return \Dapphp\TorUtils\ControlClient + */ + public function resolve($address) + { + if (is_array($address)) { + $address = implode(' ', $address); + } else if (!is_string($address)) { + throw new \Exception('$address must be a string or array; ' . gettype($address) . ' given'); + } + + $cmd = 'RESOLVE'; + $this->sendData(sprintf('%s %s', $cmd, $address)); + $reply = $this->readReply($cmd); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } + + return $this; + } + + /** + * Get the hostname of the controller + * + * @return string The controller IP/hostname + */ + public function getHost() + { + return $this->_host; + } + + /** + * Set the hostname or IP of the controller to connect to + * + * @param string $_host The hostname or IP + * @return \Dapphp\TorUtils\ControlClient + */ + public function setHost($_host) + { + $this->_host = $_host; + return $this; + } + + /** + * Get the port number of the controller + * + * @return int Port number used by the controller + */ + public function getPort() + { + return $this->_port; + } + + /** + * Set the port number of the controller to connect to + * + * @param int $_port The port number to connect to + * @return \Dapphp\TorUtils\ControlClient + */ + public function setPort($_port) + { + $this->_port = $_port; + return $this; + } + + /** + * Sets the socket timeout for connecting to the controller + * + * @param int $timeout Number of seconds to wait for controller connection before timing out + * @throws \Exception $timeout is not numeric + * @return \Dapphp\TorUtils\ControlClient + */ + public function setTimeout($timeout) + { + if (!is_numeric($timeout)) { + throw new \Exception("Timeout must be a numeric value - '{$timeout}' given"); + } + + $this->_timeout = (int)$timeout; + return $this; + } + + /** + * Gets the current timeout value for connecting + * + * @return int Connection timeout + */ + public function getTimeout() + { + return $this->_timeout; + } + + /** + * Get the setting for debugging controller communcation + * + * @return boolean true if debug output is enabled, false if not + */ + public function getDebug() + { + return $this->_debug; + } + + /** + * Set whether or not to enable debug output showing controller communication + * + * @param bool $_debug true to enable debug output, false to disable + * @return \Dapphp\TorUtils\ControlClient + */ + public function setDebug($_debug) + { + $this->_debug = (bool)$_debug; + return $this; + } + + /** + * Set the file debug output will be written to (default stdout) + * @param resource $handle A valid file handle for writing debug output + * @return \Dapphp\TorUtils\ControlClient + */ + public function setDebugOutputFile($handle) + { + if (is_resource($handle)) { + $this->_debugFp = $handle; + } + + return $this; + } + + /** + * Specify the user-defined callback for receiving data from asynchronous + * events sent by the controller. + * + * The callback must accept 2 arguments: $event, and $data where $event is + * the name of the event (e.g. ADDRMAP, NEW_CONSENSUS) and $data is the + * content of the event (typically ProtocolReply, array, or RouterDescriptor) + * + * @param callback $callback A valid callback that will be called after event data is received + * @throws \Exception If the $callback is not a callable function or method + * @return \Dapphp\TorUtils\ControlClient + */ + public function setAsyncEventHandler($callback) + { + if (!is_callable($callback)) { + throw new \Exception('Callback provided is not callable'); + } + + $ref = new \ReflectionFunction($callback); + $numargs = $ref->getNumberOfRequiredParameters(); + + if ($numargs < 2) { + throw new \Exception("Supplied callback must accept 2 arguments but it accepts $numargs"); + } + + $this->_eventCallback = $callback; + + return $this; + } + + /** + * Sends the PROTOCOLINFO command to the controller. + * + * This command must only be used once before AUTHENTICATE! + * + * @return array Array of protocol info + */ + private function _getProtocolInfo() + { + $this->sendData('PROTOCOLINFO 1'); + $reply = $this->readReply(); + + $protocolInfo = $this->_parser->parseProtocolInfo($reply); + + return $protocolInfo; + } + + /** + * Authenticate using NONE if supported + * + * @throws ProtocolError Method not supported + * @return boolean true if authenticated successfully + */ + private function authenticateNone() + { + $cmd = 'AUTHENTICATE'; + + $this->sendData($cmd); + $reply = $this->readReply($cmd); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } + + return true; + } + + /** + * Authenticate using a password + * + * @param string $password The password to send to the controller + * @throws ProtocolError Authentication failed + * @return boolean true if authenticated successfully + */ + private function authenticatePassword($password = null) + { + $password = str_replace('"', '\\\\"', $password); + $cmd = 'AUTHENTICATE'; + + $this->sendData(sprintf('%s "%s"', $cmd, $password)); + $reply = $this->readReply($cmd); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } + + return true; + } + + /** + * Authenticate using the SAFECOOKIE method. + * + * @param string $cookiePath Path to tor's auth cookie file + * @throws \Exception Cookie file not found or invalid + * @throws ProtocolError Error with authentication or wrong cookie provided + * @return boolean true if authenticated successfully + */ + private function authenticateSafecookie($cookiePath) + { + if (!file_exists($cookiePath) || !is_readable($cookiePath)) { + throw new \Exception( + sprintf('Tor control cookie file "%s" does not exist or is not readble', $cookiePath) + ); + } else if (filesize($cookiePath) != 32) { + throw new \Exception('Authentication cookie is the wrong size'); + } + + $cookie = file_get_contents($cookiePath); + + $clientNonce = $this->_generateSecureNonce(32); + $clientNonceHex = bin2hex($clientNonce); + + $cmd = 'AUTHCHALLENGE'; + + $this->sendData(sprintf('%s SAFECOOKIE %s', $cmd, $clientNonceHex)); + $reply = $this->readReply($cmd); + + if (!$reply->isPositiveReply()) { + throw new ProtocolError( + sprintf('SAFECOOKIE auth failed with code %s: %s', $reply->getStatusCode(), $reply[0]), + $reply->getStatusCode() + ); + } + + $serverhash = $servernonce = null; + + // TODO: make sure we have these... + if (preg_match('/SERVERHASH=([A-F0-9]+)/i', $reply[0], $match)) $serverhash = $match[1]; + if (preg_match('/SERVERNONCE=([A-F0-9]+)/i', $reply[0], $match)) $servernonce = $match[1]; + + $servernonceBin = hex2bin($servernonce); + + $hash = hash_hmac( + 'sha256', + $cookie . $clientNonce . $servernonceBin, + self::AUTH_SAFECOOKIE_SERVER_TO_CONTROLLER + ); + + if (hex2bin($hash) != hex2bin($serverhash)) { + throw new ProtocolError('Tor provided the wrong server nonce'); + } + + $clientHash = hash_hmac( + 'sha256', + $cookie . $clientNonce . $servernonceBin, + self::AUTH_SAFECOOKIE_CONTROLLER_TO_SERVER + ); + + $cmd = 'AUTHENTICATE'; + + $this->sendData(sprintf('%s %s', $cmd, $clientHash)); + $reply = $this->readReply($cmd); + + if (!$reply->isPositiveReply()) { + fclose($this->_sock); + throw new ProtocolError($reply[0], $reply->getStatusCode()); + } + + return true; + } + + /** + * Check if a string is a valid fingerprint + * + * @param string $string The string to check as a fingerprint + * @return bool true if valid fingerprint + */ + private function _isFingerprint($string) + { + return (bool)preg_match('/^\$?[A-F0-9]{40}$/i', $string); + } + + /** + * Check if a string is a valid nickname. Router nicknames are 1-19 + * alphanumeric characters. + * + * @param string $string The string to check as a nickname + * @return boolean true if valid nickname + */ + private function _isNickname($string) + { + return (bool)preg_match('/^[A-Z0-9]{1,19}$/i', $string); + } + + /** + * Generate a secure nonce for SAFECOOKIE authentication + * + * @param int $length Length of the secure nonce to generate + * @return string secure nonce + */ + private function _generateSecureNonce($length) + { + if (function_exists('openssl_random_pseudo_bytes')) { + $nonce = openssl_random_pseudo_bytes($length); + } else { + trigger_error('openssl extension not installed - nonce generation may be insecure', E_USER_WARNING); + $nonce = ''; + + do { + $rand = mt_rand(mt_getrandmax() / 2, mt_getrandmax()); + $nonce .= sha1(uniqid(microtime(true), true), true) . sha1($rand, true); + usleep(mt_rand(100, 50000)); + } while (strlen($nonce) < $length); + + $nonce = substr($nonce, 0, $length); + } + + return $nonce; + } + + /** + * Receive data from the controller + * + * @return string Data received + */ + private function _recvData() + { + $recv = fgets($this->_sock); + + if ($this->_debug) $this->_debugOut($recv, '<<< '); + + return $recv; + } + + /** + * Event handler for processing asynchronous replies. This method will + * parse the response and call the user defined callback if one is set. + * + * @param ProtocolReply $reply Asynchronous reply sent by the controller + */ + private function _asyncEventHandler(ProtocolReply $reply) + { + // if no callback is set, just return + // at this point the event has been read and discarded from stream + if (is_null($this->_eventCallback) || !is_callable($this->_eventCallback)) return ; + + // EVENTS + /* + * CIRC + * STREAM + * ORCONN + * BW + * *Log messages (Severity = "DEBUG" / "INFO" / "NOTICE" / "WARN"/ "ERR") + * NEWDESC + * ADDRMAP + * AUTHDIR_NEWDESCS + * DESCCHANGED + * *Status events (StatusType = "STATUS_GENERAL" / "STATUS_CLIENT" / "STATUS_SERVER") + * GUARD + * NS + * STREAM_BW + * CLIENTS_SEEN + * NEWCONSENSUS + * BUILDTIMEOUT_SET + * SIGNAL + * CONF_CHANGED + * CIRC_MINOR + * TRANSPORT_LAUNCHED + * CONN_BW + * CIRC_BW + * CELL_STATS + * TB_EMPTY + * HS_DESC + * HS_DESC_CONTENT + * NETWORK_LIVENESS + */ + $parser = new Parser(); + list($event) = explode(' ', $reply[0]); + + switch($event) { + case 'NEWCONSENSUS': + case 'NS': + $data = $parser->parseRouterStatus($reply); + break; + + case 'ADDRMAP': + $data = $parser->parseAddrMap($reply[0]); + break; + + // TODO: add more built-in parsing of events + + default: + $data = $reply; + break; + } + + call_user_func($this->_eventCallback, $event, $data); + } + + /** + * Check if a line of data sent from the controller is a positive reply (2xy) + * + * @param string $line The reply line to check + * @return boolean true if the response is of the 200 class of responses + */ + private function _isPositiveReply($line) + { + return substr($line, 0, 1) === '2'; // reply begins with 2xy + } + + /** + * Check if a line of data sent from the controller is a "MidReplyLine". A + * MidReplyLine is additional data belonging to a reply + * + * @param string $line The line to check + * @return bool true if line is a MidReplyLine + */ + private function _isMidReplyLine($line) + { + return (bool)preg_match('/^\d{3}-/', $line); + } + + /** + * Check if a line of data sent from the controller is an "EndReplyLine". + * An end reply line indicates the entire response to a command has now + * been sent. + * + * @param string $line The reply line to check + * @return bool true if line is an EndReplyLine + */ + private function _isEndReplyLine($line) + { + return (bool)preg_match('/^\d{3} .*\r\n$/', $line); + } + + /** + * Check if a line of data sent from the controller is an asynchronous + * event reply line (650). + * + * @param string $line The line to check + * @return boolean true if line is an event reply + */ + private function _isEventReplyLine($line) + { + return substr($line, 0, 3) === '650'; + } + + /** + * Write debug output message + * + * @param string $string The debug data to write + * @param string $prefix Prefix to print before the line (<<< indicates data sent from controller, >>> indiates data sent to the controller) + */ + private function _debugOut($string, $prefix) + { + fwrite($this->_debugFp, $prefix . $string); + } +} diff --git a/DirectoryClient.php b/DirectoryClient.php new file mode 100644 index 0000000..05cd438 --- /dev/null +++ b/DirectoryClient.php @@ -0,0 +1,272 @@ + + * @version 1.0 + * + */ + +namespace Dapphp\TorUtils; + +require_once 'Parser.php'; +require_once 'ProtocolReply.php'; + +use Dapphp\TorUtils\Parser; +use Dapphp\TorUtils\ProtocolReply; + +/** + * Class for getting router info from Tor directory authorities + * + */ +class DirectoryClient +{ + /** + * https://gitweb.torproject.org/tor.git/tree/src/or/config.c#n854 + * + * @var $DirectoryAuthorities List of directory authorities + */ + private $DirectoryAuthorities = array( + '7BE683E65D48141321C5ED92F075C55364AC7123' => '193.23.244.244:80', // dannenberg + '7EA6EAD6FD83083C538F44038BBFA077587DD755' => '194.109.206.212:80', // dizum + 'CF6D0AAFB385BE71B8E111FC5CFF4B47923733BC' => '154.35.175.225:80', // Faravahar + 'F2044413DAC2E02E3D6BCF4735A19BCA1DE97281' => '131.188.40.189:80', // gabelmoo + '74A910646BCEEFBCD2E874FC1DC997430F968145' => '199.254.238.52:80', // longclaw + 'BD6A829255CB08E66FBE7D3748363586E46B3810' => '171.25.193.9:443', // maatuska + '9695DFC35FFEB861329B9F1AB04C46397020CE31' => '128.31.0.34:9131', // moria1 + '4A0CCD2DDC7995083D73F5D667100C8A5831F16D' => '82.94.251.203:80', // Tonga + '847B1F850344D7876491A54892F904934E4EB85D' => '86.59.21.38:80', // tor26 + '0AD3FA884D18F89EEA2D89C019379E0E7FD94417' => '208.83.223.34:443', // urras + ); + + private $_connectTimeout = 5; + private $_userAgent = 'dapphp/TorUtils 0.1'; + + private $_parser; + private $_serverList; + + /** + * DirectoryClient constructor + */ + public function __construct() + { + $this->_serverList = $this->DirectoryAuthorities; + shuffle($this->_serverList); + + $this->_parser = new Parser(); + } + + /** + * Fetch a list of all known router descriptors on the Tor network + * + * @return array Array of RouterDescriptor objects + */ + public function getAllServerDescriptors() + { + $reply = $this->_request('/tor/server/all.z'); + + $descriptors = $this->_parser->parseDirectoryStatus($reply); + + return $descriptors; + } + + /** + * Fetch directory information about a router + * @param string|array $fingerprint router fingerprint or array of fingerprints to get information about + * @return mixed Array of RouterDescriptor objects, or a single RouterDescriptor object + */ + public function getServerDescriptor($fingerprint) + { + if (is_array($fingerprint)) { + $fp = implode('+', $fingerprint); + } else { + $fp = $fingerprint; + } + + $uri = sprintf('/tor/server/fp/%s.z', $fp); + + $reply = $this->_request($uri); + + $descriptors = $this->_parser->parseDirectoryStatus($reply); + + if (sizeof($descriptors) == 1) { + return array_shift($descriptors); + } else { + return $descriptors; + } + } + + /** + * Pick a random dir authority to query and perform the HTTP request for directory info + * + * @param string $uri Uri to request + * @param string $directoryServer IP and port of the directory to query + * @throws \Exception No authorities responded + * @return \Dapphp\TorUtils\ProtocolReply The reply from the directory authority + */ + private function _request($uri, $directoryServer = null) + { + reset($this->_serverList); + + do { + // pick a server from the list, it is randomized in __construct + $server = $this->getNextServer(); + if ($server === false) { + throw new \Exception('No more directory servers available to query'); + } + + list($host, $port) = @explode(':', $server); + if (!$port) $port = 80; + + $fp = fsockopen($host, $port, $errno, $errstr, $this->_connectTimeout); + if (!$fp) continue; + + $request = $this->_getHttpRequest('GET', $host, $uri); + + fwrite($fp, $request); + + $response = ''; + + while (!feof($fp)) { + $response .= fgets($fp); + } + + fclose($fp); + + list($headers, $body) = explode("\r\n\r\n", $response, 2); + $headers = $this->_parseHttpResponseHeaders($headers); + + if ($headers['status_code'] !== '200') { + throw new \Exception( + sprintf('Directory returned a negative response code to request. %s %s', $headers['status_code'], $headers['message']) + ); + } + + $encoding = (isset($headers['headers']['content-encoding'])) ? $headers['headers']['content-encoding'] : null; + + if ($encoding == 'deflate') { + if (!function_exists('gzuncompress')) { + throw new \Exception('Directory response was gzip compressed but PHP does not have zlib support enabled'); + } + + $body = gzuncompress($body); + if ($body === false) { + throw new \Exception('Failed to inflate response data'); + } + } else if ($encoding == 'identity') { + // nothing to do + } else { + throw new \Exception('Directory sent response in an unknown encoding: ' . $encoding); + } + + break; + } while (true); + + $reply = new ProtocolReply(); + $reply->appendReplyLine( + sprintf('%s %s', $headers['status_code'], $headers['message']) + ); + $reply->appendReplyLines(explode("\n", $body)); + + return $reply; + } + + /** + * Construct an http request for talking to a directory server + * + * @param string $method GET|POST + * @param string $host IP/hostname to query + * @param string $uri The request URI + * @return string Completed HTTP request + */ + private function _getHttpRequest($method, $host, $uri) + { + $request = sprintf( + "%s %s HTTP/1.0\r\n" . + "Host: $host\r\n" . + "Connection: close\r\n" . + "User-Agent: %s\r\n" . + "\r\n", + $method, $uri, $host, $this->_userAgent + ); + + return $request; + } + + /** + * Parse HTTP response headers from the directory reply + * + * @param string $headers String of http response headers + * @throws \Exception Response was not a valid http response + * @return array Array with http status_code, message, and lines of headers + */ + private function _parseHttpResponseHeaders($headers) + { + $lines = explode("\r\n", $headers); + $response = array_shift($lines); + $header = array(); + + if (!preg_match('/^HTTP\/\d\.\d (\d{3}) (.*)$/i', $response, $match)) { + throw new \Exception('Directory server sent a malformed HTTP response'); + } + + $code = $match[1]; + $message = $match[2]; + + foreach($lines as $line) { + if (strpos($line, ':') === false) { + throw new \Exception('Directory server sent an HTTP response line missing the ":" separator'); + } + list($name, $value) = explode(':', $line, 2); + $header[strtolower($name)] = trim($value); + } + + return array( + 'status_code' => $code, + 'message' => $message, + 'headers' => $header, + ); + } + + /** + * Get the next directory authority from the list to query + * + * @return string IP:Port of directory + */ + private function getNextServer() + { + $server = current($this->_serverList); + next($this->_serverList); + return $server; + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b27ba37 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2015, Drew Phillips +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of TorUtils nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/Parser.php b/Parser.php new file mode 100644 index 0000000..0ac183b --- /dev/null +++ b/Parser.php @@ -0,0 +1,636 @@ + + * @version 1.0 + * + */ + +namespace Dapphp\TorUtils; + +require_once 'RouterDescriptor.php'; +require_once 'ProtocolReply.php'; +require_once 'ProtocolError.php'; + +use Dapphp\TorUtils\RouterDescriptor; +use Dapphp\TorUtils\ProtocolReply; +use Dapphp\TorUtils\ProtocolError; + +/** + * Class for parsing replies from the control connection or directories. + * + * Typically, implementors will not need to use this class as it is used by + * the ControlClient and DirectoryClient to parse responses. + * + */ +class Parser +{ + private $_descriptorReplyLines = array( + 'router' => '_parseRouter', + 'platform' => '_parsePlatform', + 'published' => '_parsePublished', + 'fingerprint' => '_parseFingerprint', + 'hibernating' => '_parseHibernating', + 'uptime' => '_parseUptime', + 'onion-key' => '_parseOnionKey', + 'ntor-onion-key' => '_parseNtorOnionKey', + 'signing-key' => '_parseSigningKey', + 'accept' => '_parseAccept', + 'reject' => '_parseReject', + 'ipv6-policy' => '_parseIPv6Policy', + 'router-signature' => '_parseRouterSignature', + 'contact' => '_parseContact', + 'family' => '_parseFamily', + 'caches-extra-info' => '_parseCachesExtraInfo', + 'extra-info-digest' => '_parseExtraInfoDigest', + 'hidden-service-dir' => '_parseHiddenServiceDir', + 'bandwidth' => '_parseBandwidth', + 'protocols' => '_parseProtocols', + 'allow-single-hop-exits' + => '_parseAllowSingleHopExits', + 'or-address' => '_parseORAddress', + 'master-key-ed25519' => '_parseMasterKeyEd25519', + 'router-sig-ed25519' => '_parseRouterSigEd25519', + 'identity-ed25519' => '_parseIdentityEd25519', + 'onion-key-crosscert' + => '_parseOnionKeyCrosscert', + 'ntor-onion-key-crosscert' + => '_parseNtorOnionKeyCrosscert', + ); + + /** + * Parse directory status reply (v3 directory style) + * + * @param ProtocolReply $reply The reply to parse + * @return array Array of \Dapphp\TorUtils\RouterDescriptor objects + */ + public function parseRouterStatus(ProtocolReply $reply) + { + $descriptors = []; + $descriptor = null; + + foreach($reply->getReplyLines() as $line) { + switch($line[0][0]) { + case 'r': + if ($descriptor != null) + $descriptors[$descriptor->fingerprint] = $descriptor; + + $descriptor = new RouterDescriptor(); + $descriptor->setArray($this->_parseRLine($line)); + break; + + case 'a': + $descriptor->setArray($this->_parseALine($line)); + break; + + case 's': + $descriptor->setArray($this->_parseSLine($line)); + break; + + case 'v': + $descriptor->setArray($this->_parsePlatform($line)); + break; + + case 'w': + $descriptor->setArray($this->_parseWLine($line)); + break; + + case 'p': + $descriptor->setArray($this->_parsePLine($line)); + break; + } + } + + $descriptors[$descriptor->fingerprint] = $descriptor; + + return $descriptors; + } + + /** + * Parse a router descriptor + * + * @param ProtocolReply $reply The reply to parse + * @return array Array of \Dapphp\TorUtils\RouterDescriptor objects + */ + public function parseDirectoryStatus(ProtocolReply $reply) + { + $descriptors = []; + $descriptor = null; + + foreach($reply as $line) { + if ($line == 'OK') continue; // for DirectoryClient HTTP responses + if (trim($line) == '') continue; + + $opt = false; + + if (substr($line, 0, 4) == 'opt ') { + $opt = true; + $line = substr($line, 4); + } + + $values = explode(' ', $line, 2); if (sizeof($values) < 2) $values[1] = null; + list ($keyword, $value) = $values; + + if ($keyword == 'router') { + if ($descriptor) + $descriptors[$descriptor->fingerprint] = $descriptor; + + $descriptor = new RouterDescriptor(); + } + + if (array_key_exists($keyword, $this->_descriptorReplyLines)) { + $descriptor->setArray( + call_user_func( + array($this, $this->_descriptorReplyLines[$keyword]), $value, $reply + ) + ); + } else { + if (!$opt) { + trigger_error('No callback found for keyword ' . $keyword, E_USER_NOTICE); + } + } + } + + $descriptors[$descriptor->fingerprint] = $descriptor; + + return $descriptors; + } + + public function parseAddrMap($line) + { + if (strpos($line, 'ADDRMAP') !== 0) { + throw new \Exception('Data passed to parseAddrMap must begin with ADDRMAP'); + } + + if (!preg_match('/^ADDRMAP ([^\s]+) ([^\s]+) "([^"]+)"/', $line, $match)) { + throw new ProtocolError("Failed to parse ADDRMAP line '{$line}'"); + } + + $map = array( + 'ADDRESS' => $match[1], + 'NEWADDRESS' => $match[2], + 'EXPIRY' => $match[3], + ); + + if (preg_match('/error="?([^"\s]+)"?/', $line, $match)) { + $map['error'] = $match[1]; + } + if (preg_match('/EXPIRES="([^"]+)"/', $line, $match)) { + $map['EXPIRES'] = $match[1]; + } + if (preg_match('/CACHED="([^"]+)"/', $line, $match)) { + $map['CACHED'] = $match[1]; + } + + return $map; + } + + private function _parseRouter($line) + { + $values = explode(' ', $line); + + if (sizeof($values) < 5) { + throw new ProtocolError('Error parsing router line. Expected 5 values, got ' . sizeof($values)); + } + + return array( + 'nickname' => $values[0], + 'ip_address' => $values[1], + 'or_port' => $values[2], + /* socksport - deprecated */ + 'dir_port' => $values[4], + ); + } + + private function _parsePlatform($line) + { + return array('platform' => $line); + } + + private function _parsePublished($line) + { + $values = explode(' ', $line); + + if (sizeof($values) != 2) { + throw new ProtocolError('Error parsing published line. Expected 2 values, got ' . sizeof($values)); + } + + $date = $values[0]; + $time = $values[1]; + + // TODO: validate + + return array( + 'published' => $line, + ); + } + + private function _parseFingerprint($line) + { + return array( + 'fingerprint' => str_replace(' ', '', $line), + ); + } + + private function _parseHibernating($line) + { + return array( + 'hibernating' => $line, + ); + } + + private function _parseUptime($line) + { + if (!preg_match('/^\d+$/', $line)) { + throw new ProtocolError('Invalid uptime, expected numeric value'); + } + + return array( + 'uptime' => $line, + ); + } + + private function _parseOnionKey($line, ProtocolReply $reply) + { + $key = $this->_parseRsaKey($reply); + + return array( + 'onion_key' => $key, + ); + } + + private function _parseNtorOnionKey($line) + { + $len = strlen($line) % 4; + $line = str_pad($line, strlen($line) + $len, '='); + + if (base64_decode($line) === false) { + throw new ProtocolError('ntor-onion-key did not contain valid base64 encoded data'); + } + + return array( + 'ntor_onion_key' => $line, + ); + } + + private function _parseSigningKey($line, ProtocolReply $reply) + { + $key = $this->_parseRsaKey($reply); + + return array( + 'signing_key' => $key, + ); + } + + private function _parseAccept($line) + { + $exit = $line; + + return array( + 'exit_policy4' => array('accept' => $exit), + ); + } + + private function _parseReject($line) + { + $exit = $line; + + return array( + 'exit_policy4' => array('reject' => $exit), + ); + } + + private function _parseIPv6Policy($line) + { + list($policy, $portlist) = explode(' ', $line); + + return array( + 'exit_policy6' => array($policy => $portlist), + ); + } + + private function _parseRouterSignature($line, ProtocolReply $reply) + { + $key = $this->_parseBlockData($reply, '-----BEGIN SIGNATURE-----', '-----END SIGNATURE-----'); + + return array( + 'router_signature' => $key, + ); + } + + private function _parseContact($line) + { + return array('contact' => $line); + } + + private function _parseFamily($line) + { + return array( + 'family' => explode(' ', $line), + ); + } + + private function _parseCachesExtraInfo($line) + { + // presence of this field indicates the server caches extra info + return array('caches_extra_info' => true); + } + + private function _parseExtraInfoDigest($line) + { + return array( + 'extra_info_digest' => $line, + ); + } + + private function _parseHiddenServiceDir($line) + { + if (trim($line) == '') { + $line = '2'; + } + + return array( + 'hidden_service_dir' => $line, + ); + } + + private function _parseBandwidth($line) + { + $values = explode(' ', $line); + + if (sizeof($values) < 3) { + throw new ProtocolError('Error parsing bandwidth line. Expected 3 values, got ' . sizeof($values)); + } + + return array( + 'bandwidth_average' => $values[0], + 'bandwidth_burst' => $values[1], + 'bandwidth_observed' => $values[2], + ); + } + + private function _parseProtocols($line) + { + return array( + 'protocols' => $line, + ); + } + + private function _parseAllowSingleHopExits($line) + { + // presence of this line indicates the router allows single hop exits + return array('allow_single_hop_exits' => true); + } + + private function _parseORAddress($line) + { + return array('or_address' => $line); + } + + private function _parseMasterKeyEd25519($line) + { + return array('ed25519_key' => $line); + } + + private function _parseRouterSigEd25519($line) + { + return array('ed25519_sig' => $line); + } + + private function _parseIdentityEd25519($line, ProtocolReply $reply) + { + $cert = $this->_parseBlockData($reply, '-----BEGIN ED25519 CERT-----', '-----END ED25519 CERT-----'); + + return array( + 'ed25519_identity' => $cert, + ); + } + + private function _parseOnionKeyCrosscert($line, ProtocolReply $reply) + { + $cert = $this->_parseBlockData($reply, '-----BEGIN CROSSCERT-----', '-----END CROSSCERT-----'); + + return array( + 'onion_key_crosscert' => $cert, + ); + } + + public function _parseNtorOnionKeyCrosscert($line, ProtocolReply $reply) + { + $signbit = $line; + $cert = $this->_parseBlockData($reply, '-----BEGIN ED25519 CERT-----', '-----END ED25519 CERT-----'); + + return array( + 'ntor_onion_key_crosscert_signbit' => $signbit, + 'ntor_onion_key_crosscert' => $cert, + ); + } + + private function _parseRsaKey(ProtocolReply $reply) + { + return $this->_parseBlockData($reply, '-----BEGIN RSA PUBLIC KEY-----', '-----END RSA PUBLIC KEY-----'); + } + + private function _parseRLine($line) + { + $values = explode(' ', $line); + + return array( + 'nickname' => $values[1], + 'fingerprint' => substr(self::base64ToHexString($values[2]), 0, 40), + 'digest' => substr(self::base64ToHexString($values[3]), 0, 40), + 'published' => $values[4] . ' ' . $values[5], + 'ip_address' => $values[6], + 'or_port' => $values[7], + 'dir_port' => $values[8], + ); + } + + private function _parseALine($line) + { + $values = explode(' ', $line, 2); + $line = $values[1]; + + if (preg_match('/\[([^]]+)]+:(\d+)/', $line, $match)) { + $ip = $match[1]; + $port = $match[2]; + } else { + list($ip, $port) = explode(':', $line); + } + + return array( + 'or_port' => $port, + 'ipv6_address' => $ip, + ); + } + + private function _parseSLine($line) + { + $values = explode(' ', $line); + array_shift($values); + + return array( + 'flags' => $values, + ); + } + + private function _parseWLine($line) + { + $bandwidth = $this->_parseDelimitedData($line, 'w'); + + if (!isset($bandwidth['bandwidth'])) { + throw new ProtocolError("Bandwidth value not present in 'w' line"); + } + + return array( + 'bandwidth' => $bandwidth['bandwidth'], + 'bandwidth_measured' => (isset($bandwidth['measured']) ? $bandwidth['measured'] : null), + 'bandwidth_unmeasured' => (isset($bandwidth['unmeasured']) ? $bandwidth['unmeasured'] : null), + ); + } + + private function _parsePLine($line) + { + $values = explode(' ', $line); + + return array( + 'exit_policy4' => array($values[1] => $values[2]), + ); + } + + public function parseProtocolInfo($reply) + { + /* + 250-PROTOCOLINFO 1 + 250-AUTH METHODS=COOKIE,SAFECOOKIE,HASHEDPASSWORD COOKIEFILE="/var/run/tor/control.authcookie" + 250-VERSION Tor="0.2.4.24" + 250 OK + */ + $methods = $cookiefile = $version = null; + + if (isset($reply['AUTH'])) { + $values = $this->_parseDelimitedData($reply['AUTH']); + + if (!isset($values['methods'])) { + throw new ProtocolError('PROTOCOLINFO reply did not contain any authentication methods'); + } + + $methods = $values['methods']; + + if (isset($values['cookiefile'])) { + $cookiefile = $values['cookiefile']; + } + } else { + throw new ProtocolError('PROTOCOLINFO response did not contain AUTH line'); + } + + if (isset($reply['VERSION'])) { + $version = $this->_parseDelimitedData($reply['VERSION']); + + if (!isset($version['tor'])) { + throw new ProtocolError('PROTOCOLINFO version line did not match expected format'); + } + + $version = $version['tor']; + } else { + throw new ProtocolError('PROTOCOL INFO response did not contain VERSION line'); + } + + return array( + 'methods' => explode(',', $methods), + 'cookiefile' => $cookiefile, + 'version' => $version, + ); + } + + private function _parseBlockData(ProtocolReply $reply, $startDelimiter, $endDelimter) + { + $reply->next(); + + $line = $reply->current(); + + if ($line != $startDelimiter) { + throw new ProtocolError('Expected line beginning with "' . $startDelimiter . '"'); + } + + $key = $line . "\n"; + + do { + $reply->next(); + $line = $reply->current(); + $key .= $line . "\n"; + } while ($line && $line != $endDelimter); + + return $key; + } + + private function _parseDelimitedData($data, $prefix = null, $delimiter = '=', $boundary = ' ') + { + $return = array(); + + if ($prefix && is_string($prefix)) { + $data = preg_replace('/^' . preg_quote($prefix) . ' /', '', $data); + } + + if (strpos($data, $boundary) === false) { + $items = array($data); + } else { + $items = explode($boundary, $data); + } + + foreach ($items as $item) { + if (strpos($item, $delimiter) === false) { + trigger_error("Delimiter not found in data '" . $item . "'"); + continue; + } + + $values = explode($delimiter, $item, 2); + + $values[1] = trim($values[1], '"'); // remove surrounding quotes from data + + $return[strtolower($values[0])] = $values[1]; + } + + return $return; + } + + public static function base64ToHexString($base64) + { + $padLength = strlen($base64) % 4; + $base64 .= str_pad($base64, $padLength, '='); + $identity = base64_decode($base64); + + return strtoupper(bin2hex($identity)); + } +} diff --git a/ProtocolError.php b/ProtocolError.php new file mode 100644 index 0000000..9cef538 --- /dev/null +++ b/ProtocolError.php @@ -0,0 +1,21 @@ + + * @version 1.0 + * + */ + +namespace Dapphp\TorUtils; + +/** + * Tor ProtocolReply object. + * + * This object represents a reply from the Tor control protocol or directory + * server. The ProtocolReply holds the status code of the reply and gives + * access to individual lines of data from the response. + * + */ +class ProtocolReply implements \Iterator, \ArrayAccess +{ + private $_statusCode; + private $_command; + private $_position = 0; + private $_lines = []; + + /** + * ProtocolReply constructor. + * + * @param string $command The command for which the reply will be read + * Certain command responses reply with the command that was sent. Giving + * the command is not necessary, but will remove it from the first line of + * the reply *if* the command name was present in the reply and matched + * what was given. + */ + public function __construct($command = null) + { + $this->_command = $command; + } + + /** + * Get the name of the command set in the constructor. + * + * Note: this method will not return the actual name of the command in the + * reply, it is only set if a $command was passed to the constructor. + * + * @return string Name of the command being parsed. + */ + public function getCommand() + { + return $this->_command; + } + + /** + * Gets the status code of the reply (if set) + * + * @return int Response status code. + */ + public function getStatusCode() + { + return $this->_statusCode; + } + + /** + * Returns a string representation of the reply + * + * @return string The reply from the controller + */ + public function __toString() + { + return implode("\n", $this->_lines); + } + + /** + * Get the reply as an array of lines + * + * @return array Array of response lines + */ + public function getReplyLines() + { + return $this->_lines; + } + + /** + * Append a line to the reply and process it. Typically this function + * should not be called as it is only used by the classes for building + * the intial reply object + * + * @param string $line A line of data from the reply to append + */ + public function appendReplyLine($line) + { + $status = null; + $first = sizeof($this->_lines) == 0; + $line = rtrim($line, "\r\n"); + + if (preg_match('/^(\d{3})-' . preg_quote($this->_command, '/') . '=(.*)$/', $line, $match)) { + // ###-COMMAND=data reply... + $status = $match[1]; + $this->_lines[]= $match[2]; + } else if (preg_match('/^(\d{3})\+' . preg_quote($this->_command, '/') . '=$/', $line, $match)) { + // ###+COMMAND= + $status = $match[1]; + } else if (preg_match('/^650(?:\+|-)/', $line)) { + $status = 650; + $this->_lines[] = substr($line, 4); + } else if (preg_match('/^(\d{3})-(\w+)\s*(.*)$/', $line, $match)) { + // ###-DATA RESPONSE + $status = $match[1]; + + if ($match[1][0] != '2') { + // GETCONF can return multiple lines like "552-Unrecognized configuration key xxx" + $this->_lines[] = $match[2] . ' ' . $match[3]; + } else { + $this->_lines[$match[2]] = $match[3]; + } + } else if (preg_match('/^(\d{3})\s*(.*)$/', $line, $match)) { + // ### STATUS + $status = $match[1]; + $this->_lines[] = $match[2]; + } else { + // other data from multi-line reply + $this->_lines[] = $line; + } + + if ($status != null && $first) { + $this->_statusCode = $status; + } + } + + /** + * Append multiple lines of data to the reply. Typically this should not + * be used as it is used by the classes constructing replies. + * + * @param array $lines Array of response lines to append + */ + public function appendReplyLines(array $lines) + { + $this->_lines = array_merge($this->_lines, $lines); + } + + /** + * Tell if the status code of this reply indicates success or not + * + * @return boolean true if reply indicates success, false otherwise + */ + public function isPositiveReply() + { + if (strlen($this->_statusCode) > 0) { + return substr($this->_statusCode, 0, 1) === '2'; // reply begins with 2xy + } else { + return false; + } + } + + /** + * (non-PHPdoc) + * @see Iterator::rewind() + */ + public function rewind() + { + $this->_position = 0; + } + + /** + * (non-PHPdoc) + * @see Iterator::current() + */ + public function current() + { + $key = $this->key(); + return $this->_lines[$key]; + } + + /** + * (non-PHPdoc) + * @see Iterator::key() + */ + public function key() + { + // TODO: this *really* sucks on large replies + return array_keys($this->_lines)[$this->_position]; + } + + /** + * (non-PHPdoc) + * @see Iterator::next() + */ + public function next() + { + ++$this->_position; + } + + /** + * (non-PHPdoc) + * @see Iterator::valid() + */ + public function valid() + { + $key = $this->key(); + return isset($this->_lines[$key]); + } + + /** + * (non-PHPdoc) + * @see ArrayAccess::offsetExists() + */ + public function offsetExists($offset) + { + return isset($this->_lines[$offset]); + } + + /** + * (non-PHPdoc) + * @see ArrayAccess::offsetGet() + */ + public function offsetGet($offset) + { + return isset($this->_lines[$offset]) ? $this->_lines[$offset] : null; + } + + /** + * (non-PHPdoc) + * @see ArrayAccess::offsetSet() + */ + public function offsetSet($offset, $value) + { + if (is_null($offset)) { + $this->_lines[] = $value; + } else { + $this->_lines[$offset] = $value; + } + } + + /** + * (non-PHPdoc) + * @see ArrayAccess::offsetUnset() + */ + public function offsetUnset($offset) + { + unset($this->_lines[$offset]); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..89d41cc --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +## Name: + +**Dapphp\TorUtils** - PHP classes for interacting with the Tor control protocol, +directory authorities and servers, and DNS exit lists. + +## Version: + +**1.0** + +## Author: + +Drew Phillips + +## Requirements: + +* PHP 5.3 or greater + +## Description: + +**Dapphp\TorUtils** provides some PHP libraries for working with Tor. +The main functionality focuses on interacting with Tor using the Tor +control protocol and provides many methods to make it easy to send +commands, retrieve directory and node information, and modify Tor's +configuration using the control protocol. A few other utility classes +are provided for robustness. + +The following classes are provided: + +- ControlClient: A class for interacting with Tor's control protocol which +can be used to script your Tor relay or learn information about +other nodes in the Tor network. With this class you can query directory +information through the controller, get and set Tor configuration values, +fetch information with the GETINFO command, subscribe to events, and send +raw commands to the controller and get back parsed responses. + +- DirectoryClient: A class for querying information directly from Tor +directory authorities (or any other Tor directory server). This class +can be used to fetch information about active nodes in the network by +nickname or fingerprint or retrieve a list of all active nodes to get +information such as IP address, contact info, exit policies, certs, +uptime, flags and more. + +- TorDNSEL: A simple interface for querying an address against the Tor +DNS exit lists to see if an IP address belongs to a Tor exit node. + +## Examples: + +The source package comes with several examples of interacting with the +Tor control protocol and directory authorities. See the `examples/` +directory in the source package. + +Currently, the following examples are provided: + +- dc_GetAllDescriptors-simple.php: Uses the DirectoryClient class to query a +directory authority for a list of all currently known descriptors and prints +basic information on each descriptor. + +- dc_GetServerDescriptor-simple.php: Uses the DirectoryClient to fetch info +about a single descriptor and prints the information. + +- tc_AsyncEvents.php: Uses the ControlClient to subscribe to some events which +the controller will send to a registered callback as they are generated. + +- tc_GetConf.php: Uses the ControlClient to interact with the controller to +fetch and set Tor configuration options. + +- tc_GetInfo.php: Uses ControlClient to talk to the Tor controller to get +various pieces of information about the controller and routers on the network. + +- tc_SendData.php: Shows how to use ControlClient to send arbitrary commands +and read the response from the controller. Replies are returned as +ProtocolReply objects which give easy access to the status of the reply (e.g. +success/fail) and provides methods to access individual reply lines or +iterate over each line and process the data. + +- TorDNSEL.php: An example of using the Tor DNS Exit Lists to check if a remote +IP address connecting to a specific IP:Port combination is a Tor exit router. + +## TODO: + +The following commands are not directly implemented by ControlClient and would +need to be implemented or the implementation could communicate directly with +the controller using the provided functions to issue commands: + +- RESETCONF +- SAVECONF +- MAPADDRESS +- EXTENDCIRCUIT +- SETCIRCUITPURPOSE +- ATTACHSTREAM +- POSTDESCRIPTOR +- REDIRECTSTREAM +- CLOSESTREAM +- CLOSECIRCUIT +- USEFEATURE +- LOADCONF +- TAKEOWNERSHIP +- DROPGUARDS +- HSFETCH +- ADD_ONION / DEL_ONION +- HSPOST + +## Copyright: + + Copyright (c) 2015 Drew Phillips + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + - Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + - Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. diff --git a/RouterDescriptor.php b/RouterDescriptor.php new file mode 100644 index 0000000..9f8b968 --- /dev/null +++ b/RouterDescriptor.php @@ -0,0 +1,225 @@ + + * @version 1.0 + * + */ + +namespace Dapphp\TorUtils; + +/** + * RouterDescriptor class. This class holds all the data relating to a Tor + * node on the network such as nickname, fingerprint, IP address etc. + * + */ +class RouterDescriptor +{ + /** @var string The OR's nickname */ + public $nickname; + + /** @var string Hash of its identity key, encoded in base64, with trailing equals sign(s) removed */ + public $fingerprint; + + /** @var string Hash of its most recent descriptor as signed, encoded in base64 */ + public $digest; + + /** @var string Publication time of its most recent descriptor as YYYY-MM-DD HH:MM:SS, in UTC */ + public $published; + + /** @var string OR's current IP address */ + public $ip_address; + + /** @var string OR's current IPv6 address (if using IPv6) */ + public $ipv6_address; + + /** @var int OR's current port */ + public $or_port; + + /** @var int OR's current directory port, or 0 for none */ + public $dir_port; + + /** @var array Additional IP addresses of the OR */ + public $or_address = array(); + + /** @var string The version of the Tor protocol that this relay is running */ + public $platform; + + /** @var string Contact info for the OR as given by the operator */ + public $contact; + + /** @var array Array of relay nicknames or hex digests run by an operator */ + public $family; + + /** @var int OR uptime in seconds at the time of publication */ + public $uptime; + + /** @var bool resent only if the router allows single-hop circuits to make exit connections. Most Tor relays do not support this */ + public $allow_single_hop_exits = false; + + /** @var bool Present only if this router is a directory cache that provides extra-info documents */ + public $caches_extra_info = false; + + /** @var string a public key in PEM format. This key is used to encrypt CREATE cells for this OR */ + public $onion_key; + + /** @var string base-64-encoded-key. A public key used for the ntor circuit extended handshake */ + public $ntor_onion_key; + + /** @var a public key in PEM format. The OR's long-term identity key. It MUST be 1024 bits. */ + public $signing_key; + + /** @var string The "SIGNATURE" object contains a signature of the PKCS1-padded hash of the entire server descriptor */ + public $router_signature; + + /** @var string Ed25519 master key */ + public $ed25519_key; + + /** @var string Ed25519 router signature */ + public $ed25519_sig; + + /** @var string Ed25519 identity key in PEM format */ + public $ed25519_identity; + + /** @var string RSA signature of sha1 hash of identity key & ed25519 identity key */ + public $onion_key_crosscert; + + /** @var string Ed25519 certificate */ + public $ntor_onion_key_crosscert; + + /** @var string sign bit of the ntor_onion_key_crosscert */ + public $ntor_onion_key_crosscert_signbit; + + /** @var string space-separated sequences of numbers, to indicate which protocols the server supports. As of 30 Mar 2008, specified protocols are "Link 1 2 Circuit 1" */ + public $protocols; + + /** @var string a hex-encoded digest of the router's extra-info document, as signed in the router's extra-info */ + public $extra_info_digest; + + /** @var bool Present only if this router stores and serves hidden service descriptors. */ + public $hidden_service_dir; + + /** @var int An estimate of the bandwidth of this relay, in an arbitrary unit (currently kilobytes per second) */ + public $bandwidth; + + /** @var int indicates a measured bandwidth currently produced by measuring stream capacities */ + public $bandwidth_measured; + + /** @var int From consensus when bandwidth value is not based on a threshold of 3 or more measurements for this relay */ + public $bandwidth_unmeasured; + + + /** @var int volume per second that the OR is willing to sustain over long periods */ + public $bandwidth_average; + + /** @var int volume that the OR is willing to sustain in very short intervals */ + public $bandwidth_burst; + + /** @var int Estimate of the capacity this relay can handle */ + public $bandwidth_observed; + + /** @var array Node status flags (e.g. Exit, Fast, Guard, Running, Stable, Valid) */ + public $flags = array(); + + /** @var array IPv4 exit policy $exit_policy4['reject'] = array() and $exit_policy4['accept'] = array() */ + public $exit_policy4 = array(); + + /** @var array IPv6 exit policy $exit_policy6['reject'] = array() and $exit_policy6['accept'] = array() */ + public $exit_policy6 = array(); + + /** + * Set one or more descriptor values from an array + * + * @param array $values Array of key=>value properties to set + * @return \Dapphp\TorUtils\RouterDescriptor + */ + public function setArray(array $values) + { + foreach ($values as $key => $value) { + if ($key === 'exit_policy4' || $key === 'exit_policy6') { + if (!is_array($this->$key)) + $this->$key = array(); + + if (!isset($this->{$key}['accept'])) + $this->{$key}['accept'] = array(); + + if (!isset($this->{$key}['reject'])) + $this->{$key}['reject'] = array(); + + if (isset($value['accept'])) { + array_push($this->{$key}['accept'], $value['accept']); + } else if (isset($value['reject'])) { + array_push($this->{$key}['reject'], $value['reject']); + } + } else if ($key === 'or_address') { + array_push($this->{$key}, $value); + } else if (property_exists($this, $key)) { + $this->$key = $value; + } + } + + return $this; + } + + /** + * Get the properties of this descriptor as an array + * + * @return array Array of descriptor information + */ + public function getArray() + { + $return = array(); + + foreach ($this as $key => $value) { + $return[$key] = $value; + } + + return $return; + } + + /** + * Return the current calculated uptime of the node based on when the + * descriptor was published and the current time + * + * @return int|NULL null if $published was not set, or # of seconds the node has been up + */ + public function getCurrentUptime() + { + if (isset($this->published) && isset($this->uptime)) { + return $this->uptime + time() - strtotime($this->published . ' GMT'); + } else { + return null; + } + } +} diff --git a/TorDNSEL.php b/TorDNSEL.php new file mode 100644 index 0000000..14ad7a4 --- /dev/null +++ b/TorDNSEL.php @@ -0,0 +1,394 @@ + + * @version 1.0 + * + */ + +namespace Dapphp\TorUtils; + +// TODO: modify class to talk directly to DNS over UDP so we can choose the DNS server to query + +class TorDNSEL +{ + private $_requestTimeout = 10; + + /** + * Perform a DNS lookup of an IP-port combination to the public Tor DNS + * exit list service. + * + * This function determines if the remote IP address is a Tor exit node + * that permits connections to the specified IP:Port combination. + * + * @param string $ip IP address (dotted quad) of the local server + * @param string $port Numeric port the remote client is connecting to (e.g. 80, 443, 53) + * @param string $remoteIp IP address of the client (potential Tor exit) to look up + * @param string $dnsServer The DNS server to query (by default queries exitlist.torproject.org) + * @return boolean true if the $remoteIp is a Tor exit node that allows connections to $ip:$port + */ + public static function IpPort($ip, $port, $remoteIp, $dnsServer = 'exitlist.torproject.org') + { + $dnsel = new self(); + + // construct a hostname in the format of {rip}.{port}.{ip}.ip-port.exitlist.torproject.org + // where {ip} is the destination IP address and {port} is the destination port + // and {rip} is the remote (user) IP address which may or may not be a Tor router exit address + + $host = implode('.', array_reverse(explode('.', $remoteIp))) . + '.' . $port . '.' . + implode('.', array_reverse(explode('.', $ip))) . + '.ip-port' . + '.exitlist.torproject.org'; + + return $dnsel->_dnsLookup($host, $dnsServer); + } + + private function __construct() {} + + /** + * Perform a DNS lookup to the Tor DNS exit list service and determine + * if the remote connection could be a Tor exit node. + * + * @param string $host hostname in the designated tordnsel format + * @param string $dnsServer IP/host of the DNS server to use for querying + * @throws \Exception DNS failures, socket failures + * @return boolean + */ + private function _dnsLookup($host, $dnsServer) + { + $query = $this->_generateDNSQuery($host); + $data = $this->_performDNSLookup($query, $dnsServer); + + if (!$data) { + throw new \Exception('DNS request timed out'); + } + + $response = $this->_parseDNSResponse($data); + + //var_dump($response); + + switch($response['header']['RCODE']) { + case 0: + if (isset($response['answers'][0]) && '127.0.0.2' == $response['answers'][0]['data']) { + return true; + } else { + return false; + } + break; + + case 1: + throw new \Exception('The name server was unable to interpret the query.'); + break; + + case 2: + throw new \Exception('Server failure - The name server was unable to process this query due to a problem with the name server.'); + break; + + case 3: + // nxdomain + return false; + break; + + case 4: + throw new \Exception('Not Implemented - The name server does not support the requested kind of query.'); + break; + + case 5: + throw new \Exception('Refused - The name server refuses to perform the specified operation for policy reasons.'); + break; + + default: + throw new \Exception("Bad RCODE in DNS response. RCODE = '{$response['RCODE']}'"); + break; + } + } + + /** + * Generate a DNS query to send to the DNS server. This generates a + * simple DNS "A" query for the given hostname. + * + * @param string $host Hostname used in the query + * @return string + */ + private function _generateDNSQuery($host) + { + $id = rand(1, 0x7fff); + $req = pack('n6', + $id, // Request ID + 0x100, // standard query + 1, // # of questions + 0, // answer RRs + 0, // authority RRs + 0 // additional RRs + ); + + foreach(explode('.', $host) as $bit) { + // split name levels into bits + $l = strlen($bit); + // append query with length of segment, and the domain bit + $req .= chr($l) . $bit; + } + + // null pad the name to indicate end of record + $req .= "\0"; + + $req .= pack('n2', + 1, // type A + 1 // class IN + ); + + return $req; + } + + /** + * Send UDP packet containing DNS request to the DNS server + * + * @param string $query DNS query + * @param string $dns_server Server to query + * @param number $port Port number of the DNS server + * @throws \Exception Failed to send UDP packet + * @return string DNS response or empty string if request timed out + */ + private function _performDNSLookup($query, $dns_server, $port = 53) + { + $fp = fsockopen('udp://' . $dns_server, $port, $errno, $errstr); + + if (!$fp) { + throw new \Exception("Faild to send DNS request. Error {$errno}: {$errstr}"); + } + + fwrite($fp, $query); + + socket_set_timeout($fp, $this->_requestTimeout); + $resp = fread($fp, 8192); + + return $resp; + } + + /** + * Parses the DNS response + * + * @param string $data DNS response + * @throws \Exception Failed to parse response (malformed) + * @return array Array with parsed response + */ + private function _parseDnsResponse($data) + { + $p = 0; + $offset = array(); + $header = array(); + $rsize = strlen($data); + + if ($rsize < 12) { + throw new \Exception('DNS lookup failed. Response is less than 12 octets'); + } + + // read back transaction ID + $id = unpack('n', substr($data, $p, 2)); + $p += 2; + $header['ID'] = $id[1]; + + // read query flags + $flags = unpack('n', substr($data, $p, 2)); + $flags = $flags[1]; + $p += 2; + + // read flag bits + $header['QR'] = ($flags >> 15); + $header['Opcode'] = ($flags >> 11) & 0x0f; + $header['AA'] = ($flags >> 10) & 1; + $header['TC'] = ($flags >> 9) & 1; + $header['RD'] = ($flags >> 8) & 1; + $header['RA'] = ($flags >> 7) & 1; + $header['RCODE'] = ($flags & 0x0f); + + // read count fields + $counts = unpack('n4', substr($data, $p, 8)); + $p += 8; + + $header['QDCOUNT'] = $counts[1]; + $header['ANCOUNT'] = $counts[2]; + $header['NSCOUNT'] = $counts[3]; + $header['ARCOUNT'] = $counts[4]; + + $records = array(); + $records['questions'] = array(); + $records['answers'] = array(); + $records['authority'] = array(); + $records['additional'] = array(); + + for ($i = 0; $i < $header['QDCOUNT']; ++$i) { + $records['questions'][] = $this->_readDNSQuestion($data, $p); + } + + for ($i = 0; $i < $header['ANCOUNT']; ++$i) { + $records['answers'][] = $this->_readDNSRR($data, $p); + } + + for ($i = 0; $i < $header['NSCOUNT']; ++$i) { + $records['authority'][] = $this->_readDNSRR($data, $p); + } + + for ($i = 0; $i < $header['ARCOUNT']; ++$i) { + $records['additional'][] = $this->_readDNSRR($data, $p); + } + + return array( + 'header' => $header, + 'questions' => $records['questions'], + 'answers' => $records['answers'], + 'authority' => $records['authority'], + 'additional' => $records['additional'], + ); + } + + /** + * Read a DNS name from a response + * + * @param string $data The DNS response packet + * @param number $offset Starting offset of $data to begin reading + * @return string The DNS name in the packet + */ + private function _readDNSName($data, &$offset) + { + $name = array(); + + do { + $len = substr($data, $offset, 1); + $offset += 1; + + if ($len == "\0") { + // null terminator + break; + } else if ($len == "\xC0") { + // pointer or sequence of names ending in pointer + $off = unpack('n', substr($data, $offset - 1, 2)); + $offset += 1; + $noff = $off[1] & 0x3fff; + $name[] = $this->_readDNSName($data, $noff); + break; + } else { + // name segment precended by the length of the segment + $len = unpack('C', $len); + $name[] = substr($data, $offset, $len[1]); + $offset += $len[1]; + } + } while (true); + + return implode('.', $name); + } + + /** + * Read a DNS question section + * + * @param string $data The DNS response packet + * @param number $offset Starting offset of $data to begin reading + * @return array Array with question information + */ + private function _readDNSQuestion($data, &$offset) + { + $question = array(); + $name = $this->_readDNSName($data, $offset); + + $type = unpack('n', substr($data, $offset, 2)); + $offset += 2; + $class = unpack('n', substr($data, $offset, 2)); + $offset += 2; + + $question['name'] = $name; + $question['type'] = $type[1]; + $question['class'] = $class[1]; + + return $question; + } + + /** + * Read a DNS resource record + * + * @param string $data The DNS response packet + * @param number $offset Starting offset of $data to begin reading + * @return array Array with RR information + */ + private function _readDNSRR($data, &$offset) + { + $rr = array(); + + $rr['name'] = $this->_readDNSName($data, $offset); + + $fields = unpack('nTYPE/nCLASS/NTTL/nRDLENGTH', substr($data, $offset, 10)); + $offset += 10; + + $rdata = substr($data, $offset, $fields['RDLENGTH']); + $offset += $fields['RDLENGTH']; + + $rr['TYPE'] = $fields['TYPE']; + $rr['CLASS'] = $fields['CLASS']; + $rr['TTL'] = $fields['TTL']; + $rr['SIZE'] = $fields['RDLENGTH']; + $rr['RDATA'] = $rdata; + + switch($rr['TYPE']) { + /* + A 1 a host address + NS 2 an authoritative name server + MD 3 a mail destination (Obsolete - use MX) + MF 4 a mail forwarder (Obsolete - use MX) + CNAME 5 the canonical name for an alias + SOA 6 marks the start of a zone of authority + MB 7 a mailbox domain name (EXPERIMENTAL) + MG 8 a mail group member (EXPERIMENTAL) + MR 9 a mail rename domain name (EXPERIMENTAL) + NULL 10 a null RR (EXPERIMENTAL) + WKS 11 a well known service description + PTR 12 a domain name pointer + HINFO 13 host information + MINFO 14 mailbox or mail list information + MX 15 mail exchange + TXT 16 text strings + */ + case 1: // A + $addr = unpack('Naddr', $rr['RDATA']); + $rr['data'] = long2ip($addr['addr']); + break; + + case 2: // NS + $temp = $offset - $fields['RDLENGTH']; + $rr['data'] = $this->_readDNSName($data, $temp); + break; + } + + return $rr; + } +} diff --git a/examples/TorDNSEL.php b/examples/TorDNSEL.php new file mode 100644 index 0000000..0f85ab9 --- /dev/null +++ b/examples/TorDNSEL.php @@ -0,0 +1,49 @@ +getMessage()); + } +} + +// Practical usage on a web server: +/* +try { + $isTor = TorDNSEL::IpPort( + $_SERVER['SERVER_ADDR'], + $_SERVER['SERVER_PORT'], + $_SERVER['REMOTE_ADDR'] + ); + var_dump($isTor); +} catch (\Exception $ex) { + echo $ex->getMessage() . "\n"; +} +*/ diff --git a/examples/common.php b/examples/common.php new file mode 100644 index 0000000..68f0069 --- /dev/null +++ b/examples/common.php @@ -0,0 +1,46 @@ + 31536000, + 'days' => 86400, + 'hours' => 3600, + 'minutes' => 60, + 'seconds' => 1 + ); + + $return = array(); + + foreach($units as $unit => $secs) { + $num = intval($seconds / $secs); + + if ($num > 0) { + $return[$unit] = $num; + } + $seconds %= $secs; + } + + if ($array) { + return $return; + } else { + $s = ''; + foreach($return as $unit => $value) { + $s .= "$value $unit, "; + } + $s = substr($s, 0, -2); + return $s; + } +} + +/* +Original author: http://jeffreysambells.com/2012/10/25/human-readable-filesize-php +*/ +function humanFilesize($bytes, $decimals = 2) { + $size = array('B','kB','MB','GB','TB','PB','EB','ZB','YB'); + $factor = floor((strlen($bytes) - 1) / 3); + return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @$size[$factor]; +} diff --git a/examples/dc_GetAllDescriptors-simple.php b/examples/dc_GetAllDescriptors-simple.php new file mode 100644 index 0000000..c7abcd0 --- /dev/null +++ b/examples/dc_GetAllDescriptors-simple.php @@ -0,0 +1,31 @@ +getAllServerDescriptors(); + +echo sprintf("We know about %d descriptors.\n\n", sizeof($descriptors)); + +foreach($descriptors as $descriptor) { + echo sprintf("%-19s %s %16s:%s\n", $descriptor->nickname, $descriptor->fingerprint, $descriptor->ip_address, $descriptor->or_port); + + echo sprintf("Running: %s\n", $descriptor->platform); + echo sprintf("Uptime: %s\n", uptimeToString($descriptor->getCurrentUptime(), false)); + echo sprintf("Contact: %s\n", $descriptor->contact); + echo sprintf("Bandwidth (avg / burst / observed): %d / %d / %d\n", $descriptor->bandwidth_average, $descriptor->bandwidth_burst, $descriptor->bandwidth_observed); + + if (sizeof($descriptor->or_address) > 0) + echo sprintf("OR Address: %68s\n", implode(', ', $descriptor->or_address)); + + echo sprintf( + "Exit Policy:\n accept: %s\n reject: %s\n", + isset($descriptor->exit_policy4['accept']) ? implode(' ', $descriptor->exit_policy4['accept']) : '', + implode(' ', $descriptor->exit_policy4['reject']) + ); + + echo str_pad('', 80, '-') . "\n"; +} diff --git a/examples/dc_GetServerDescriptor-simple.php b/examples/dc_GetServerDescriptor-simple.php new file mode 100644 index 0000000..0185919 --- /dev/null +++ b/examples/dc_GetServerDescriptor-simple.php @@ -0,0 +1,26 @@ +getServerDescriptor('FE32CAC855ABC707ED7FEDAF720046FE914EB491'); + +echo sprintf("%-19s %40s\n", $descriptor->nickname, $descriptor->fingerprint); +echo sprintf("Running %s\n", $descriptor->platform); +echo sprintf("Online for %s\n", uptimeToString($descriptor->getCurrentUptime(), false)); +echo sprintf("OR Address: %s:%s", $descriptor->ip_address, $descriptor->or_port); + +if ($descriptor->or_address) { + foreach ($descriptor->or_address as $address) { + echo ", $address"; + } +} +echo "\n"; + +echo sprintf("Exit Policy:\n Accept:\n %s\n Reject:\n %s\n", + implode("\n ", $descriptor->exit_policy4['accept']), + implode("\n ", $descriptor->exit_policy4['reject']) +); diff --git a/examples/tc_GetConf.php b/examples/tc_GetConf.php new file mode 100644 index 0000000..4fdd45e --- /dev/null +++ b/examples/tc_GetConf.php @@ -0,0 +1,62 @@ +connect(); // connect to 127.0.0.1:9051 + $tc->authenticate(); +} catch (\Exception $ex) { + echo "Failed to create Tor control connection: " . $ex->getMessage() . "\n"; + exit; +} + +// Get configuration values for 4 Tor options +try { + $config = $tc->getConf('BandwidthRate Nickname SocksPort ORPort'); + // $config is array where key is the option and value is the current setting + + foreach($config as $keyword => $value) { + echo "Config value {$keyword} = {$value}\n"; + } +} catch (ProtocolError $pe) { + echo 'GETCONF failed: ' . $pe->getMessage(); +} + +echo "\n"; + +// Get configuration values with non-existent values +// GETCONF fails if any unknown options are present +try { + $config = $tc->getConf('ORPort NonExistentConfigValue DirPort AnotherFakeValue'); +} catch (ProtocolError $pe) { + echo 'GETCONF failed: ' . $pe->getMessage(); +} + +echo "\n\n"; + +// Read config values into array +$config = $tc->getConf('Log CookieAuthentication'); +var_dump($config); + +//$config['Log'] = 'notice stderr'; +//$config['Log'] = 'notice file /var/log/tor/tor.log'; + +// SETCONF using previously fetched config values +$tc->setConf($config); + +// SETCONF with non-existent option +// SETCONF fails and nothing is set if any unknown options are present +try { + // add non-existent config value to array + $config['IDontExist'] = 'some string value'; + $tc->setConf($config); +} catch (\Exception $ex) { + echo $ex->getMessage(); +} + +$tc->quit(); diff --git a/examples/tc_GetInfo.php b/examples/tc_GetInfo.php new file mode 100644 index 0000000..9186dd1 --- /dev/null +++ b/examples/tc_GetInfo.php @@ -0,0 +1,91 @@ +controller communication +//$tc->setDebug(true); + +try { + $tc->connect(); // connect to 127.0.0.1:9051 + $tc->authenticate(); +} catch (\Exception $ex) { + echo "Failed to create Tor control connection: " . $ex->getMessage() . "\n"; + exit; +} + +// ask controller for tor version +$ver = $tc->getVersion(); + +echo "*** Connected ***\n*** Controller is running Tor $ver ***\n"; + +try { + // get tor node's external ip, if known. + // If Tor could not determine IP, an exception is thrown + $address = $tc->getInfoAddress(); +} catch (ProtocolError $pex) { + $address = 'Unknown'; +} + +try { + // get router fingerprint (if any) - clients will not have a fingerprint + $fingerprint = $tc->getInfoFingerprint(); +} catch (ProtocolError $pex) { + $fingerprint = $pex->getMessage(); +} + +echo sprintf("*** Controller IP Address: %s / Fingerprint: %s ***\n", $address, $fingerprint); + +// ask controller how many bytes Tor has transferred +$read = $tc->getInfoTrafficRead(); +$writ = $tc->getInfoTrafficWritten(); + +echo sprintf("*** Tor traffic (read / written): %s / %s ***\n", humanFilesize($read), humanFilesize($writ)); + +echo "\n"; + +try { + // fetch info for this descriptor from controller + $descriptor = $tc->getInfoDescriptor('drew010relay01'); + // if descriptor found, query directory info to get flags + $dirinfo = $tc->getInfoDirectoryStatus($descriptor->fingerprint); + + echo "== Descriptor Info ==\n" . + "Nickname : {$descriptor->nickname}\n" . + "Fingerprint : {$descriptor->fingerprint}\n" . + "Running : {$descriptor->platform}\n" . + "Uptime : " . uptimeToString($descriptor->getCurrentUptime(), false) . "\n" . + "OR Address(es): " . $descriptor->ip_address . ':' . $descriptor->or_port; + + if (sizeof($descriptor->or_address) > 0) { + echo ', ' . implode(', ', $descriptor->or_address); + } + echo "\n" . + "Contact : {$descriptor->contact}\n" . + "BW (observed) : " . number_format($descriptor->bandwidth_observed) . " B/s\n" . + "BW (average) : " . number_format($descriptor->bandwidth_average) . " B/s\n" . + "Flags : " . implode(' ', $dirinfo->flags) . "\n\n"; +} catch (ProtocolError $pe) { + // doesn't necessarily mean the node doesn't exist + // the controller may not have updated directory info yet + echo $pe->getMessage(); // Unrecognized key "desc/name/drew010relay01 +} + +try { + echo "Sending heartbeat signal to controller..."; + + $tc->signal(ControlClient::SIGNAL_HEARTBEAT); + // watch tor.log file for heartbeat message + + echo "OK"; +} catch (ProtocolError $pe) { + echo $pe->getMessage(); +} + +echo "\n\n"; + +$tc->quit(); diff --git a/examples/tc_SendData.php b/examples/tc_SendData.php new file mode 100644 index 0000000..955de04 --- /dev/null +++ b/examples/tc_SendData.php @@ -0,0 +1,72 @@ +controller communication +//$tc->setDebug(true); + +try { + $tc->connect(); // connect to 127.0.0.1:9051 + $tc->authenticate(); +} catch (\Exception $ex) { + echo "Failed to create Tor control connection: " . $ex->getMessage() . "\n"; + exit; +} + +try { + // send arbitrary command; use GETINFO command with 'entry-guards' parameter + $tc->sendData('GETINFO entry-guards'); + + // read and parse controller response into a ProtocolReply object + $reply = $tc->readReply(); + + // show the status code of the command, and output the raw response + printf("Reply status: %d\n", $reply->getStatusCode()); + echo $reply . "\n\n"; // invokes __toString() to return the server reply + + // get an array of response lines + $lines = $reply->getReplyLines(); + + echo "Entry Guard(s):\n"; + + for ($i = 1; $i < sizeof($lines); ++$i) { + // iterate over each line skipping the first line which was the status + // match the fingerprint, nickname, and router status of the entry guards + if (preg_match('/\$?([\w\d]{40})(~|=)([\w\d]{1,19}) ([\w-]+)/', $lines[$i], $match)) { + echo " Nickname = '{$match[3]}' / Fingerprint = '{$match[1]}' / Status = '{$match[4]}'\n"; + } else { + echo " {$lines[$i]}\n"; + } + } + + echo "\n"; +} catch (ProtocolError $pe) { + echo sprintf( + "Command failed: Controller reponse %s: %s\n", + $pe->getStatusCode(), + $pe->getMessage() + ); +} + +// send unrecognized command - check whether reply was successful +$tc->sendData('FAKE_COMMAND data data data'); + +// read the reply +$reply = $tc->readReply(); + +// isPositiveReply returns true if the command returned a successful response. +if (false == $reply->isPositiveReply()) { + // show the status code and reply from the controller + echo "Command failed: " . $reply->getStatusCode() . ' ' . $reply[0] . "\n"; + + // yields: Command failed: 510 Unrecognized command "FAKE_COMMAND" +} + +echo "\n"; + +$tc->quit();