*/ /** This class allow to start a TCP server and call a function each time a * client is connected on it. Each client is separated in a child, so the * server allow to have multiple simultaneous connections. * * The handler method or function will get one parameter : the client from * socket_accept. It will allow to use socket_getpeername to get the peer * address and port, socket_read to get the data from the client, * socket_write to write on the client, socket_shutdown ($client) and * socket_close ($client) to finish the connection * * The server has a child limit set to 500 connections by default */ class tcpserver { //////////////////// // PROPERTIES // //////////////////// // {{{ /** Allow to debug with messages on screen */ private $debug = false; /** Store the data concerning the sockets and the handlers */ private $handlers = array (); /** Store the addresses */ private $addresses = array (); /** Store the ports */ private $ports = array (); /** The max number of children. The maximum of concurrent connections */ private $maxChild = 500; /** The timeout if not used a channel */ private $timeout = 30; /** Set to true in parent, and false in child */ private $parent = true; /** The socket in the child */ private $socket = null; /** Read in Text mode or in Binary mode */ private $readMode = "text"; /** Stop the new connections */ private $loopStop = false; /** The number of active clients */ private $nbChild = 0; /** The PID number when loop in background is active */ private $pidLoopInBackground; /** Server name displayed in process list */ private $processName = "tcpserver"; // }}} //////////////////////// // PUBLIC METHODS // //////////////////////// /** The constructor add the error handler needed to catch the error on * stream_select () when the process is killed */ public function __construct () // {{{ { $this->logDebug ("NEW OBJECT CONSTRUCTED"); set_error_handler([$this, 'errorHandler']); } // }}} /** The destructor is a log for debug */ public function __destruct () // {{{ { $this->logDebug ("Object destructed"); } // }}} /** Set/get the max children, the maximum of concurrent connections * @param integer|null $val The number of child to get/set */ final public function maxChild ($val = null) // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if ($val === null) return $this->maxChild; $this->maxChild = intval ($val); return $this; } // }}} /** Set/get the read mode : text or binary * @param string|null $val The mode to set (or get if null) */ final public function readMode ($val = null) // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if ($val === null) return $this->readMode; if ($val !== "text" && $val !== "binary") throw new \Exception ("Invalid readMode provided (nor text nor binary)", 500); $this->readMode = $val; return $this; } // }}} /** Set the process name displayed in system * @param string|null $val The name of the process to set (or get if null) */ final public function processName ($val = null) // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if ($val === null) return $this->processName; $this->processName = $val; return $this; } // }}} /** Set the timeout for open communication without any data transmited. * @param string|null $val The number of seconds to set (or get if null) */ final public function timeout ($val = null) // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if ($val === null) return $this->timeout; if (! is_int ($val)) throw new \Exception (dgettext ("domframework", "tcpserver : invalid timeout provided : not integer value"), 500); if ($val <= 0) throw new \Exception (dgettext ("domframework", "tcpserver : invalid timeout provided : negatif or null value"), 500); $this->timeout = intval ($val); return $this; } // }}} /** Set the address, port and handler that will be enabled by loop * @param string $address The server address (can be 0.0.0.0 for all IPv4 * interfaces or :: for all the IPv4 and IPv6 interfaces) * @param integer $port The port to listen * @param callable $handler The handler that will be called when a client is * connected to the address:port */ final public function init ($address, $port, $handler) // {{{ { $this->logMethods (__METHOD__, func_get_args ()); $this->nbChild = 0; $this->handlers["$address:$port"] = $handler; $this->addresses[] = $address; $this->ports[] = $port; return $this; } // }}} /** Start the main loop after the init and keep in it until loopStop */ final public function loop () // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if (empty ($this->addresses)) throw new \Exception (dgettext ("domframework", "Can not start TCP server loop without addresses initialized"), 500); if (empty ($this->ports)) throw new \Exception (dgettext ("domframework", "Can not start TCP server loop without port initialized"), 500); foreach ($this->ports as $port) { if ($port < 1024 && posix_getuid() !== 0) throw new \Exception (dgettext ("domframework", "Can not start TCP server on port $port without root user"), 500); } $this->logDebug ("Initialize the signal managers"); //declare (ticks = 1); pcntl_async_signals (true); pcntl_signal (SIGCHLD, [$this, "sigCHLD"]); pcntl_signal (SIGTERM, [$this, "sigTERMINT"]); pcntl_signal (SIGINT, [$this, "sigTERMINT"]); cli_set_process_title ($this->processName." main"); foreach ($this->addresses as $key => $address) { $port = $this->ports[$key]; if (filter_var ($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) $address = "$address"; elseif (filter_var ($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) $address = "[$address]"; else throw new \Exception ("Can't create socket : invalid address provided"); $sockServer[$key] = stream_socket_server ("tcp://$address:$port", $errno, $errstr); if ($sockServer[$key] === false) { throw new \Exception ("Can't create socket : $errstr"); } stream_set_timeout ($sockServer[$key], $this->timeout); $this->logDebug ("Listening on $address:$port..."); } $ppid = posix_getppid (); while (1) { if (posix_getppid () !== $ppid) { $this->logDebug ("PARENT PID change : ".posix_getppid () . " !== $ppid : STOP"); exit; } if ($this->loopStop) { $this->logDebug ("Do not accept new connections ($this->nbChild)"); foreach ($sockServer as $socket) stream_socket_shutdown ($socket, STREAM_SHUT_RD); if ($this->nbChild === 0) { $this->logDebug ("No more childs and loopStop requested". " : end of process"); exit; } sleep (2); continue; } $read = $sockServer; $write = null; $except = null; // catch the errors, as a kill on a pending stream_select display a PHP // Warning if (@stream_select ($read, $write, $except, 0, 200000) < 1) { pcntl_signal_dispatch (); continue; } foreach ($read as $sock) { $client = stream_socket_accept ($sock); $name = stream_socket_get_name ($client, false); $pos = strrpos ($name, ":"); $localPort = substr ($name, $pos+1); $localAddress = substr ($name, 0, $pos); $name = stream_socket_get_name ($client, true); $pos = strrpos ($name, ":"); $port = substr ($name, $pos+1); $address = substr ($name, 0, $pos); $handlerAddress = $localAddress; if (! key_exists ("$handlerAddress:$localPort", $this->handlers)) { // If the address of the handler doesn't exists, the socket is maybe // waiting on all the interfaces addresses $handlerAddress = str_replace (["[", "]"], ["",""], $handlerAddress); if (filter_var ($handlerAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) $handlerAddress = "0.0.0.0"; elseif (filter_var ($handlerAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) $handlerAddress = "::"; if (! key_exists ("$handlerAddress:$localPort", $this->handlers)) { printf ("Can't find the handler for %s:%d\n", $handlerAddress, $localPort); continue; } } if (substr ($address, 0, 8) === "[::ffff:") $address = substr ($address, 8, -1); if (substr ($localAddress, 0, 7) === "[::ffff:") $localAddress = substr ($localAddress, 8, -1); $this->logDebug ("New connection $address:$port > ". "$localAddress:$localPort : ". "use handler $handlerAddress:$localPort"); $handler = $this->handlers["$handlerAddress:$localPort"]; // We got a connection fork to manage it if ($this->nbChild > $this->maxChild) { printf ("Too much child process %d : Abort the connection !!\n", $this->nbChild); @stream_socket_shutdown ($client, STREAM_SHUT_RDWR); continue; } $pid = pcntl_fork(); if ($pid == -1) { throw new \Exception ("TCPServer can not fork", 500); } else if ($pid) { // parent process: return to the main loop $this->nbChild++; continue; } $this->logDebug ("Child start ($address:$port) : ". ($this->nbChild+1)." child active"); // Do not stop if the parent is requesting a stop from Ctrl+C cli_set_process_title ($this->processName." ($address:$port)"); pcntl_signal(SIGINT, SIG_IGN); $sid = posix_setsid(); // In the child. Will call the handler with the actual tcpserver object // as parameter $this->parent = false; $this->socket = $client; if (is_array ($handler)) { $object = $handler[0]; $method = $handler[1]; $object->$method ($this); } else { $function = $handler; $function ($this); } $this->logDebug ("Child ended ($address:$port)"); exit; } } } // }}} /** Request the loop to stop. Will not allow new connections, but wait the * end of the existing processus * Block until all is closed */ final public function loopStop () // {{{ { $this->logMethods (__METHOD__, func_get_args ()); $this->loopStop = true; } // }}} /** Start the main loop in background and do not wait its end * @return the PID of the child */ final public function loopInBackgroundStart () // {{{ { $this->logMethods (__METHOD__, func_get_args ()); $this->logDebug ("Start loopInBackground"); $pid = pcntl_fork(); $this->pidLoopInBackground = $pid; if ($pid == -1) { throw new \Exception ("TCPServer can not fork in background", 500); } else if ($pid) { $this->logDebug ("loopInBackground : child = ". $this->pidLoopInBackground); // parent process: return to the main loop return $pid; } $sid = posix_setsid(); // Will catch all the text messages from the application to not crash if // there is an "echo" ob_start (); // Catch the error messages from the application to not hang if triggered // An other handler can be set in function to execute set_error_handler ([$this, "errorHandler"]); set_exception_handler ([$this, "exceptionHandler"]); @fclose (STDIN); @fclose (STDOUT); @fclose (STDERR); $this->loop (); exit; } // }}} /** Stop the main loop in background and wait until its end */ final public function loopInBackgroundStop () // {{{ { $this->logMethods (__METHOD__, func_get_args ()); $this->logDebug ("Request loopInBackgroundStop"); posix_kill ($this->pidLoopInBackground, SIGINT); pcntl_waitpid ($this->pidLoopInBackground, $status); $this->logDebug ("Request loopInBackgroundStop : END"); } // }}} /** In child, get the socket to direct access * @return resource The socket with the client */ final public function getSock () // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if ($this->parent === true) throw new \Exception ("Can not return the socket in parent mode", 500); if ($this->socket === null) throw new \Exception ("Can not send to client : not connected", 500); return $this->socket; } // }}} /** Get an array with the peer address, peer port, local address and local * port * @return array array ("peer address", peer port, "local address", local * port) */ final public function getInfo () // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if ($this->parent === true) throw new \Exception ("Can not get info in parent mode", 500); if ($this->socket === null) throw new \Exception ("Can not send to client : not connected", 500); $name = stream_socket_get_name ($this->socket, false); $pos = strrpos ($name, ":"); $localPort = substr ($name, $pos+1); $localAddress = substr ($name, 0, $pos); $name = stream_socket_get_name ($this->socket, true); $pos = strrpos ($name, ":"); $port = substr ($name, $pos+1); $address = substr ($name, 0, $pos); if (substr ($address, 0, 8) === "[::ffff:") $address = substr ($address, 8, -1); if (substr ($localAddress, 0, 7) === "[::ffff:") $localAddress = substr ($localAddress, 8, -1); return array ($address, $port, $localAddress, $localPort); } // }}} /** Activate the SSL connection * Put the socket in blocking mode, as it is mandatory to have SSL connection * @param boolean $val True to activate, false to disable SSL * @param integer $cryptoMethod The cryptoMethod allowed */ final public function cryptoEnable ($val, $cryptoMethod = STREAM_CRYPTO_METHOD_TLS_SERVER) // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if ($this->socket === null) throw new \Exception ("Can not send to server $this->ipOrName : ". "The server is not connected", 500); // Setting the options allow the IP to be decided by the connect and valid // the certificate of the server by the name $options = array ("ssl" => array ( "verify_peer_name" => false, )); stream_set_blocking ($this->socket, true); stream_context_set_option ($this->socket, $options); return @stream_socket_enable_crypto ($this->socket, !!$val, $cryptoMethod); } // }}} /** Set context SSL option. * @param array $options The ssl array to set */ final public function setSSLOptions ($options) // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if ($this->socket === null) throw new \Exception ("Can not send to server $this->ipOrName : ". "The server is not connected", 500); return stream_context_set_option ($this->socket, array ("ssl" => $options)); } // }}} /** Send data to the client * @param mixed $data The data to send * @return the length of data sent */ final public function send ($data) // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if ($this->parent === true) throw new \Exception ("Can not send data in parent mode", 500); if ($this->socket === null) throw new \Exception ("Can not send to client : not connected", 500); $length = strlen ($data); $sentLen = fwrite ($this->socket, $data); if ($sentLen < $length) throw new \Exception ("Can not send data to client", 500); $this->logSend ($data); return $sentLen; } // }}} /** Read the data from the client. * The connection must be established * Use the readMode in text or binary (text by default) * In text mode, the read return when found the first \r or the first \n. * @param integer $maxLength Limit the length of the data from the server * @return The content */ final public function read ($maxLength = 1024) // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if ($this->parent === true) throw new \Exception ("Can not read data in parent mode", 500); if ($this->socket === null) throw new \Exception ("Can not read from client : not connected", 500); stream_set_timeout ($this->socket, $this->timeout); if ($this->readMode === "text") $read = stream_get_line ($this->socket, $maxLength, "\r\n"); else $read = @fread ($this->socket, $maxLength); $meta = stream_get_meta_data ($this->socket); if (isset ($meta["timed_out"]) && $meta["timed_out"] === true) { $this->timeoutHandler(); return false; } if ($read === false) throw new \Exception ("Can not read from client : ". error_get_last ()["message"], 500); $this->logReceive ($read); return $read; } // }}} /** Disconnect the socket */ final public function disconnect () // {{{ { $this->logMethods (__METHOD__, func_get_args ()); if ($this->parent === true) throw new \Exception ("Can not send data in parent mode", 500); if ($this->socket === null) throw new \Exception ("Can not disconnect client : not connected", 500); @stream_socket_shutdown ($this->socket, STREAM_SHUT_RDWR); $this->socket = null; } // }}} /** Log the data send to the client. By default, do nothing, but can be * overrided by the user * @param string $data The data to store in log */ public function logSend ($data) // {{{ { if (! $this->debug) return; $data = rtrim ($data)."\n"; file_put_contents ("/tmp/debug", date ("H:i:s")." [".posix_getpid (). "] S> $data", FILE_APPEND); } // }}} /** Log the data received from the client. By default, do nothing, but can be * overrided by the user * @param string $data The data to store in log */ public function logReceive ($data) // {{{ { if (! $this->debug) return; $data = rtrim ($data)."\n"; file_put_contents ("/tmp/debug", date ("H:i:s")." [".posix_getpid (). "] C> $data", FILE_APPEND); } // }}} /** Log the methods called. By default, do nothing, but can be overrided by * the user * @param string $method The data to store in log * @param mixed|null $args The data to store in log */ public function logMethods ($method, $args) // {{{ { if (! $this->debug) return; $data = $method." ("; foreach ($args as $key => $arg) { if ($key > 0) $data .= ", "; if (is_string ($arg)) $data .= "\"$arg\""; elseif (is_numeric ($arg)) $data .= "$arg"; elseif ($arg === false) $data .= "false"; elseif ($arg === true) $data .= "true"; elseif ($arg === null) $data .= "null"; elseif (is_array ($arg)) $data .= "['".implode ("','", $arg."']"); elseif (is_object ($arg)) $data .= ""; else $data .= "UNKNOWN : ".gettype ($arg); } $data.= ")\n"; file_put_contents ("/tmp/debug", date ("H:i:s")." [".posix_getpid (). "] METHOD $data", FILE_APPEND); } // }}} /** Log the debug, By defaul do nothing, but can be overrided by the user * @param mixed|null $params The data to store in log */ public function logDebug ($params) // {{{ { if (! $this->debug) return; file_put_contents ("/tmp/debug", date ("H:i:s")." [".posix_getpid (). "] DEBUG $params\n", FILE_APPEND); } // }}} /** Log the errors, By defaul do nothing, but can be overrided by the user * @param mixed|null $params The data to store in log */ public function logError ($params) // {{{ { if (! $this->debug) return; file_put_contents ("/tmp/debug", date ("H:i:s")." [".posix_getpid (). "] ERROR $params\n", FILE_APPEND); } // }}} /** Error catcher. * By default, do nothing, but can be overrided by the user * @param integer $errNo The error number * @param string $errMsg The error message * @param string $file The file name * @param integer $line The line where the error raised */ public function errorHandler ($errNo, $errMsg, $file, $line) // {{{ { // @-operator : error suppressed if (0 === error_reporting()) return false; $this->logError ("line $line : $errMsg"); } // }}} /** Exception catcher * By default do nothing, but can be overrided by the user * @param object $exception The exception to catch */ public function exceptionHandler ($exception) // {{{ { $this->logError ("Exception ".$exception->getMessage () . " (". $exception->getFile ().":".$exception->getLine().")"); } // }}} /** Manage the timeout exception handler * By default, disconnect and generate an exception */ public function timeoutHandler () // {{{ { $this->disconnect (); throw new \Exception (dgettext ("domframework", "Disconnected for inactivity"), 500); } // }}} ///////////////////////// // PRIVATE METHODS // ///////////////////////// /** Manage the child stop signal */ private function sigCHLD () // {{{ { $this->nbChild --; $this->logDebug ("One child finished : $this->nbChild childs remain ". "active"); //pcntl_wait ($status, WNOHANG); $rc = pcntl_wait ($status); $this->logDebug ( "One child finished : $rc"); } // }}} /** Manage the term / int signals * Will catch the stop signal, but the real end will be done when the last * child will be closed */ private function sigTERMINT () // {{{ { $this->logDebug ("Request TERM/INT : Wait for last childs"); $this->loopStop (); } // }}} }