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();