diff --git a/bin/orbit b/bin/orbit index 388a122..00c7bc2 100755 --- a/bin/orbit +++ b/bin/orbit @@ -1,105 +1,27 @@ #!/usr/bin/env php "UK", - "stateOrProvinceName" => "Somerset", - "localityName" => "Glastonbury", - "organizationName" => "The Brain Room Limited", - "organizationalUnitName" => "PHP Documentation Team", - "commonName" => "Wez Furlong", - "emailAddress" => "wez@example.com" +require_once dirname(__DIR__) . "/vendor/autoload.php"; + +// Define command line args for this client +// And fetch args passed in by invocation +$args = new \Qi_Console_ArgV( + $argv, + array( + 'host|h:' => 'Set host/ip address to listen on (default 0.0.0.0)', + 'port|p:' => 'Set port to listen on (default 1965)', + 'pem-cert:' => 'Set cert PEM file to use (default ./server.pem)', + 'log:' => 'Set log filename (default orbit.log)', + 'help|h' => 'Show help', + 'verbose|v' => 'Include more verbose output', + 'quiet|q' => 'Print less messages', + 'no-color' => 'Don\'t use color output', + 'version' => 'Show version and exit', + ) ); +$terminal = new \Qi_Console_Terminal(); +$error_handler = new \Qi_Console_ExceptionHandler($terminal, true); +$console = new \Orbit\Console($args, $terminal); -// Generate certificate -$privkey = openssl_pkey_new(); -$cert = openssl_csr_new($dn, $privkey); -$cert = openssl_csr_sign($cert, null, $privkey, 365); - -// Generate PEM file -# Optionally change the passphrase from 'comet' to whatever you want, or leave it empty for no passphrase -$pem_passphrase = 'comet'; -$pem = array(); -openssl_x509_export($cert, $pem[0]); -openssl_pkey_export($privkey, $pem[1], $pem_passphrase); -$pem = implode($pem); - -// Save PEM file -$pemfile = './server.pem'; -file_put_contents($pemfile, $pem); - -$context = stream_context_create(); - -// local_cert must be in PEM format -stream_context_set_option($context, 'ssl', 'local_cert', $pemfile); -// Pass Phrase (password) of private key -stream_context_set_option($context, 'ssl', 'passphrase', $pem_passphrase); - -stream_context_set_option($context, 'ssl', 'allow_self_signed', true); -stream_context_set_option($context, 'ssl', 'verify_peer', false); - -// Create the server socket -$server = stream_socket_server('ssl://0.0.0.0:1965', $errno, $errstr, STREAM_SERVER_BIND|STREAM_SERVER_LISTEN, $context); - -while (true) { - //$buffer = ''; - print "waiting..."; - $client = @stream_socket_accept($server); - - if ($client) { - print "accepted " . stream_socket_get_name($client, true) . "\n"; - $request = stream_get_line($client, 1024, "\r\n"); - // Read until CRLF - //while(!preg_match('/\r\n/', $buffer) ) - // $buffer .= fread($client, 2046); - print(View::hexView($request)); - - // Respond to client - $response = implode("\r\n", ['20 text/gemini', 'Thanks for all the fish']); - fwrite($client, $response); - fclose($client); - } else { - print "error.\n"; - } -} - -class View -{ - /** - * View hex chars of string - * - * Outputs a listing of hexidecimal values in 16 byte rows - * - * @param mixed $text Input text - * @return string - */ - public static function hexView($text) - { - $num = 16; - $outStr = ''; - $printableChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' - . 'abcdefghijklmnopqrstuvwxyz' - . '0123456789~!@#$%^&*()_+-={}|[]\:";\'<>?,./'; - - $charCount = strlen($text); - for ($i = 0; $i < $charCount; $i += $num) { - $printStr = ''; - for ($j = 0; $j < $num; $j++) { - $char = substr($text, $i+$j, 1); - - $outStr .= sprintf("%02X", ord($char)) . " "; - - if (ord($char) >= 32 && ord($char) < 127) { - $printStr .= $char; - } else { - $printStr .= "."; - } - } - - $outStr .= " | " . $printStr . "\n"; - } - - return $outStr; - } -} +$value = $console->execute(); +exit($value); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6a95eae --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "name": "sumpygump/orbit", + "description": "Server for gemini protocol", + "license": "MIT", + "authors": [ + { + "name": "Jansen Price", + "email": "sumpygump@tilde.tam" + } + ], + "require": { + "sumpygump/qi-console": "^1.3", + "monolog/monolog": "^2.1" + }, + "autoload": { + "psr-4" : { + "Orbit\\" : "src/Orbit" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..87d54fb --- /dev/null +++ b/composer.lock @@ -0,0 +1,202 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "f2158d38eb2be637af2512ebe8b515b2", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "f9eee5cec93dfb313a38b6b288741e84e53f02d5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/f9eee5cec93dfb313a38b6b288741e84e53f02d5", + "reference": "f9eee5cec93dfb313a38b6b288741e84e53f02d5", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/log": "^1.0.1" + }, + "provide": { + "psr/log-implementation": "1.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^6.0", + "graylog2/gelf-php": "^1.4.2", + "php-amqplib/php-amqplib": "~2.4", + "php-console/php-console": "^3.1.3", + "php-parallel-lint/php-parallel-lint": "^1.0", + "phpspec/prophecy": "^1.6.1", + "phpunit/phpunit": "^8.5", + "predis/predis": "^1.1", + "rollbar/rollbar": "^1.3", + "ruflin/elastica": ">=0.90 <3.0", + "swiftmailer/swiftmailer": "^5.3|^6.0" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "php-console/php-console": "Allow sending log messages to Google Chrome", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "http://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2020-07-23T08:41:23+00:00" + }, + { + "name": "psr/log", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "time": "2020-03-23T09:12:05+00:00" + }, + { + "name": "sumpygump/qi-console", + "version": "1.3.3", + "source": { + "type": "git", + "url": "https://github.com/sumpygump/qi-console.git", + "reference": "f3afecb5fe238799a09f38082379a3cd9057eb07" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sumpygump/qi-console/zipball/f3afecb5fe238799a09f38082379a3cd9057eb07", + "reference": "f3afecb5fe238799a09f38082379a3cd9057eb07", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "7.5.*" + }, + "type": "library", + "autoload": { + "psr-0": { + "Qi_Console_": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jansen Price", + "email": "jansen.price@gmail.com" + } + ], + "description": "Console utilities", + "homepage": "http://quantalideas.com/code/qi-console", + "keywords": [ + "console", + "terminal", + "terminfo" + ], + "time": "2020-08-26T20:40:44+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "1.1.0" +} diff --git a/src/Orbit/Config.php b/src/Orbit/Config.php new file mode 100644 index 0000000..aed8579 --- /dev/null +++ b/src/Orbit/Config.php @@ -0,0 +1,13 @@ +_args->version) { + $this->showVersion(); + return 0; + } + + if ($this->_args->help) { + $this->showHelp(); + return 0; + } + + $config = new Config(); + + if ($this->_args->host) { + $config->host = $this->_args->host; + } + + if ($this->_args->port) { + $config->port = $this->_args->port; + } + + if ($this->_args->log) { + $config->log_file = $this->_args->log; + } + + if ($this->_args->quiet) { + $config->quiet = $this->_args->quiet; + } + + if ($this->_args->verbose) { + $config->verbose = $this->_args->verbose; + } + + if (!$config->quiet) { + print "Orbit // Gemini server software\n"; + } + $server = new Server($config); + + $server->setLogger($this->makeLogger($config)); + $server->listen(); + } + + public function makeLogger($config) + { + $pid = getmypid(); + $output = "[%datetime%] $pid %channel%.%level_name%: %message% %context%\n"; + $formatter = new LineFormatter($output, 'Y-m-d\TH:i:s'); + + $logger = new Logger('orbit'); + + $level = Logger::INFO; + if ($config->verbose) { + $level = Logger::DEBUG; + } + + $log_stream = new StreamHandler($config->log_file, $level); + $log_stream->setFormatter($formatter); + $logger->pushHandler($log_stream); + + if (!$config->quiet) { + $std_stream = new StreamHandler('php://stdout', $level); + $std_stream->setFormatter($formatter); + $logger->pushHandler($std_stream); + } + + return $logger; + } + + public function showVersion() + { + print "Orbit " . Server::$version . "\n"; + } + + public function showHelp() + { + $this->showVersion(); + + $out = $this->_terminal->do_op() + . "Usage: orbit [options]\n" + . "\n" + . $this->_terminal->do_setaf(3) + . "Options:\n" + . $this->_terminal->do_op(); + + foreach ($this->_args->getHelp() as $key => $value) { + if (strpos($key, "|")) { + $parts = explode("|", $key); + $left = sprintf("-%s, --%s", $parts[0], $parts[1]); + } else { + $left = sprintf(" --%s", $key); + } + + if ($left[-1] == ":") { + $left = substr($left, 0, -1) . " "; + } + + $out .= sprintf( + " %s%s %s%s\n", + $this->_terminal->do_setaf(2), + str_pad($left, 20), + $this->_terminal->do_op(), + $value, + ); + } + + print $out; + } +} diff --git a/src/Orbit/Request.php b/src/Orbit/Request.php new file mode 100644 index 0000000..c812b21 --- /dev/null +++ b/src/Orbit/Request.php @@ -0,0 +1,26 @@ +url = $request_input; + $data = parse_url($request_input); + + foreach ($data as $key => $value) { + $this->{$key} = $value; + } + } +} diff --git a/src/Orbit/Server.php b/src/Orbit/Server.php new file mode 100644 index 0000000..811da65 --- /dev/null +++ b/src/Orbit/Server.php @@ -0,0 +1,154 @@ +config = new Config(); + } else { + $this->config = $config; + } + + $this->timeout = ini_get("default_socket_timeout"); + + if (file_exists($this->pemfile)) { + $this->log(Logger::DEBUG, "Using existing cert."); + } else { + $this->log(Logger::DEBUG, "Generating new cert."); + $this->generateCert(); + } + $this->ssl_context = $this->createSslContext(); + } + + public function setLogger(Logger $logger) + { + $this->logger = $logger; + } + + public function getLogger() + { + if (!$this->logger) { + $this->logger = new Logger('orbit'); + $this->logger->pushHandler(new StreamHandler($this->config->log_file, Logger::INFO)); + } + + return $this->logger; + } + + public function listen() + { + $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); + } + + $protocol = "gemini"; + $name = stream_socket_get_name($server, false); + $this->log(Logger::NOTICE, "Listening on $protocol://$name ..."); + + while (true) { + # This is to swallow up the `timeout` warning + set_error_handler([$this, 'onWarning']); + $client = stream_socket_accept($server, $this->timeout, $client_name); + restore_error_handler(); + + if ($client) { + $this->log(Logger::DEBUG, "$client_name Accepted"); + $request_buffer = stream_get_line($client, 1024, "\r\n"); + $this->log(Logger::INFO, "REQ: $request_buffer", ["client" => $client_name]); + $request = new Request($request_buffer); + + // Respond to client + $response = implode("\r\n", ['20 text/gemini', "Thanks for all the fish\n"]); + fwrite($client, $response); + fclose($client); + $this->log(Logger::DEBUG, "$client_name Closed"); + } + } + } + + public function log($level, $message, $context = []) + { + $this->getLogger()->log($level, $message, $context); + } + + public function onWarning($id, $message) + { + if (strpos($message, "Operation timed out") !== false) { + // Do nothing + return; + } + + // Something else happened. + throw new \Exception($id . ' ' . $message); + } + + public function getListenAddress() + { + return sprintf('tls://%s:%s', $this->config->host, $this->config->port); + } + + private function generateCert() + { + // Certificate data + $dn = [ + "countryName" => "UK", + "stateOrProvinceName" => "X", + "localityName" => "X", + "organizationName" => "X", + "organizationalUnitName" => "X", + "commonName" => "orbit1.0", + "emailAddress" => "X" + ]; + + // Generate certificate + $privkey = openssl_pkey_new(); + $cert = openssl_csr_new($dn, $privkey); + $cert = openssl_csr_sign($cert, null, $privkey, 365); + + // Generate PEM file + $pem = []; + openssl_x509_export($cert, $pem[0]); + openssl_pkey_export($privkey, $pem[1], $this->pem_passphrase); + $pem = implode($pem); + + // Save PEM file + file_put_contents($this->pemfile, $pem); + } + + public function createSslContext() + { + $context = stream_context_create(); + + // local_cert must be in PEM format + stream_context_set_option($context, 'ssl', 'local_cert', $this->pemfile); + stream_context_set_option($context, 'ssl', 'passphrase', $this->pem_passphrase); + + stream_context_set_option($context, 'ssl', 'allow_self_signed', true); + stream_context_set_option($context, 'ssl', 'verify_peer', false); + + return $context; + } +}