config = $config; if ($logger !== null) { $this->setLogger($logger); } else { $this->logger = new Logger('orbit'); $this->logger->pushHandler(new StreamHandler($this->config->log_file, Logger::INFO)); } if ($cert == null) { $this->cert = new Cert($this->config, $this->getLogger()); } else { $this->cert = $cert; } $this->timeout = (int) ini_get("default_socket_timeout"); $this->ssl_context = $this->createSslContext(); } /** * Set logger object * * @param Logger $logger */ public function setLogger(Logger $logger): void { $this->logger = $logger; } /** * Get logger * * @return Logger */ public function getLogger(): Logger { return $this->logger; } /** * Listen and handle incoming requests * * @return void */ public function listen(): void { $root_dir = $this->config->root_dir; if (!is_dir($root_dir)) { throw new \Exception("Error: Root directory '$root_dir' not a directory"); } $this->logger->debug(sprintf("Root directory '%s'", realpath($root_dir))); $server = stream_socket_server( $this->getListenAddress(), $errno, $errstr, STREAM_SERVER_BIND|STREAM_SERVER_LISTEN, $this->ssl_context ); if (!$server) { throw new \Exception("Error " . $errno . ": " . $errstr); } stream_set_blocking($server, true); $name = stream_socket_get_name($server, false); $this->logger->info(sprintf("Listening on %s://%s...", self::SCHEME, $name)); $this->connections = []; $this->peers = []; $this->metas = []; $_write = null; $_exception = null; while (true) { // Handle incoming new connections $this->intakeConnections($server); if (count($this->connections) == 0) { continue; } $this->peers = array_values($this->connections); // Handle reading from and responding to connections if (stream_select($this->peers, $_write, $_exception, 5)) { foreach ($this->peers as $client) { $this->handlePeerConnection($client); } } } } /** * Intake connections * * @param resource $server * @return bool */ public function intakeConnections($server): bool { if (!is_resource($server)) { throw new \Exception("Stream server must be resource object"); } set_error_handler([$this, 'onWarning']); # onWarning is added here to swallow up the `timeout` warning $client = stream_socket_accept($server, $this->timeout, $peer); restore_error_handler(); if (!$client) { # If the listener times out, it will run this and then start # listening again $this->pruneExpiredConnections(); return false; } $time = ['start' => microtime(true)]; $meta = stream_get_meta_data($client); $this->logger->debug("$peer Accepted", $meta); $this->connections[$peer] = $client; $this->metas[$peer] = ["input" => "", "time" => $time]; return true; } /** * Handle a single peer connection (client request) * * @param resource $client * @return void */ public function handlePeerConnection($client): void { $peer = stream_socket_get_name($client, true); if (!$peer) { // If it went away use the array key as the name to close it $peer = array_search($client, $this->connections); } $this->metas[$peer]['input'] .= fread($client, 1026); if (mb_strlen($this->metas[$peer]['input']) >= 1024 || strpos($this->metas[$peer]['input'], "\r\n") !== false ) { $request_buffer = $this->metas[$peer]['input']; //print(Util::hexView($request_buffer)); //print("Length: " . mb_strlen($request_buffer) . "\n"); $this->logger->info("REQ: $request_buffer", ['client' => $peer]); $request = new Request($request_buffer); // Respond to client $response = $this->handle($request); $size = 0; try { $size = $response->send($client); } catch (\Exception $e) { $this->logger->error($e->getMessage()); } $time_end = microtime(true); $this->logger->debug( "RSP: " . trim($response->getHeader()), ['size' => $size, 'time' => $time_end - $this->metas[$peer]['time']['start']] ); fclose($client); $this->logger->debug("$peer Closed"); unset($this->connections[$peer]); unset($this->metas[$peer]); } } /** * Handle creating a response and sending it to a client * * @param Request $request * @return Response */ public function handle(Request $request): Response { [$is_valid, $response] = $this->validateRequest($request); if ($is_valid === false) { // If not valid, just return the error response object created by // the validator return $response; } $statics_module = new Statics($this->config, $this->logger); [$success, $response] = $statics_module->handle($request); return $response; } /** * Prune any lingering connections * * Remove any connections that are still 'open' but have been open past a * certain threshold timeout * * @return void */ public function pruneExpiredConnections(): void { $now = microtime(true); foreach ($this->metas as $peer => $meta) { $delta = $now - $meta['time']['start']; if ($delta > $this->timeout) { $this->logger->debug(sprintf("Pruning expired connection %s (%.02f seconds over)", $peer, $delta)); if (isset($this->connections[$peer])) { unset($this->connections[$peer]); } unset($this->metas[$peer]); } } } /** * Validate a request * * Will return an tuple (array with two values). The first value is whether * it passed the validation logic. The second value is the Response object. * * @param Request $request * @return array [bool, Response] */ public function validateRequest(Request $request): array { $response = new Response(); // Valid URL must contain a host if (!$request->host) { $response->setStatus(Response::STATUS_BAD_REQUEST); $response->setMeta("Bad request - url is empty"); return [false, $response]; } // Valid URL must be the target scheme if ($request->scheme != self::SCHEME) { $response->setStatus(Response::STATUS_PROXY_REQUEST_REFUSED); $response->setMeta("Proxy error - unsupported scheme"); return [false, $response]; } // Valid URL must use targeted hostname if ($request->host != "127.0.0.1" && $request->host != "localhost" && $request->host != $this->config->hostname ) { $response->setStatus(Response::STATUS_PROXY_REQUEST_REFUSED); $response->setMeta("Proxy error - invalid host"); return [false, $response]; } // Valid URL must use correct port if ($request->port != "" && $request->port != $this->config->port) { $response->setStatus(Response::STATUS_PROXY_REQUEST_REFUSED); $response->setMeta("Proxy error - invalid port"); return [false, $response]; } // Valid URL must not be more than 1024 chars if (mb_strlen($request->url) > 1024) { $response->setStatus(Response::STATUS_BAD_REQUEST); $response->setMeta("Bad request - too long"); return [false, $response]; } // Valid URL must not contain non-UTF-8 bytes $conv = Encoding::fixUTF8($request->url); if ($conv != $request->url) { $response->setStatus(Response::STATUS_BAD_REQUEST); $response->setMeta("Bad request - non-UTF8"); return [false, $response]; } return [true, $response]; } /** * Special error handler * * For warnings that occur when attempting to start server stream * * @param int $errno * @param string $errstr * @return bool */ public function onWarning($errno, $errstr): bool { if (strpos($errstr, "timed out") !== false) { // Do nothing return true; } // Something else happened. $message = sprintf("Error while accepting connection %s: %s", $errno, $errstr); $this->logger->error($message); return true; } /** * Get the listen address for the stream_server * * @return string */ public function getListenAddress(): string { return sprintf('tls://%s:%s', $this->config->host, $this->config->port); } /** * Create SSL context for stream server * * @return resource */ public function createSslContext() { $context = stream_context_create(); // local_cert must be in PEM format stream_context_set_option($context, 'ssl', 'local_cert', $this->cert->tls_certfile); stream_context_set_option($context, 'ssl', 'local_pk', $this->cert->tls_keyfile); stream_context_set_option($context, 'ssl', 'passphrase', $this->cert->key_passphrase); stream_context_set_option($context, 'ssl', 'allow_self_signed', true); stream_context_set_option($context, 'ssl', 'verify_peer', false); return $context; } }