2020-09-06 09:32:56 +02:00
|
|
|
<?php declare(strict_types=1);
|
2020-08-27 00:57:01 +02:00
|
|
|
|
|
|
|
namespace Orbit;
|
|
|
|
|
|
|
|
use Monolog\Logger;
|
|
|
|
use Monolog\Handler\StreamHandler;
|
2020-09-03 21:11:09 +02:00
|
|
|
use ForceUTF8\Encoding;
|
2020-08-27 00:57:01 +02:00
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
use Orbit\Module\Statics;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Server
|
|
|
|
*
|
|
|
|
* @package Orbit
|
|
|
|
*/
|
2020-08-27 00:57:01 +02:00
|
|
|
class Server
|
|
|
|
{
|
2020-09-03 22:59:02 +02:00
|
|
|
const SCHEME = "gemini";
|
|
|
|
|
2020-09-28 21:06:12 +02:00
|
|
|
public static $version = "0.5";
|
2020-08-27 00:57:01 +02:00
|
|
|
|
|
|
|
public $config;
|
2020-08-31 08:04:44 +02:00
|
|
|
public $cert;
|
2020-08-27 00:57:01 +02:00
|
|
|
public $timeout = 60;
|
|
|
|
|
|
|
|
private $ssl_context;
|
|
|
|
private $logger;
|
2020-09-04 07:27:04 +02:00
|
|
|
private $connections = []; // Incoming client connections
|
|
|
|
private $peers = []; // Client connections to read from
|
|
|
|
private $metas = []; // Meta data for each client connection
|
2020-08-27 00:57:01 +02:00
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* Constructor
|
|
|
|
*
|
|
|
|
* @param Config $config
|
|
|
|
* @param Cert $cert
|
|
|
|
* @param Logger $logger
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function __construct(Config $config, ?Cert $cert = null, ?Logger $logger = null)
|
2020-08-27 00:57:01 +02:00
|
|
|
{
|
2020-09-06 09:32:56 +02:00
|
|
|
$this->config = $config;
|
2020-08-27 00:57:01 +02:00
|
|
|
|
2020-08-27 05:16:05 +02:00
|
|
|
if ($logger !== null) {
|
|
|
|
$this->setLogger($logger);
|
2020-08-31 08:04:44 +02:00
|
|
|
} else {
|
2020-09-06 09:32:56 +02:00
|
|
|
$this->logger = new Logger('orbit');
|
|
|
|
$this->logger->pushHandler(new StreamHandler($this->config->log_file, Logger::INFO));
|
2020-08-27 05:16:05 +02:00
|
|
|
}
|
|
|
|
|
2020-08-31 08:04:44 +02:00
|
|
|
if ($cert == null) {
|
2020-09-06 09:32:56 +02:00
|
|
|
$this->cert = new Cert($this->config, $this->getLogger());
|
2020-08-27 00:57:01 +02:00
|
|
|
} else {
|
2020-08-31 08:04:44 +02:00
|
|
|
$this->cert = $cert;
|
2020-08-27 00:57:01 +02:00
|
|
|
}
|
2020-08-31 08:04:44 +02:00
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
$this->timeout = (int) ini_get("default_socket_timeout");
|
2020-08-31 08:04:44 +02:00
|
|
|
|
2020-08-27 00:57:01 +02:00
|
|
|
$this->ssl_context = $this->createSslContext();
|
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* Set logger object
|
|
|
|
*
|
|
|
|
* @param Logger $logger
|
|
|
|
*/
|
|
|
|
public function setLogger(Logger $logger): void
|
2020-08-27 00:57:01 +02:00
|
|
|
{
|
|
|
|
$this->logger = $logger;
|
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* Get logger
|
|
|
|
*
|
|
|
|
* @return Logger
|
|
|
|
*/
|
|
|
|
public function getLogger(): Logger
|
2020-08-27 00:57:01 +02:00
|
|
|
{
|
|
|
|
return $this->logger;
|
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* Listen and handle incoming requests
|
|
|
|
*
|
|
|
|
* @return void
|
|
|
|
*/
|
2020-09-08 00:05:38 +02:00
|
|
|
public function listen(): void
|
2020-08-27 00:57:01 +02:00
|
|
|
{
|
2020-09-08 00:05:38 +02:00
|
|
|
$root_dir = $this->config->root_dir;
|
2020-08-27 08:37:46 +02:00
|
|
|
|
2020-08-31 08:04:44 +02:00
|
|
|
if (!is_dir($root_dir)) {
|
|
|
|
throw new \Exception("Error: Root directory '$root_dir' not a directory");
|
2020-08-27 08:37:46 +02:00
|
|
|
}
|
|
|
|
|
2020-09-08 00:05:38 +02:00
|
|
|
$this->logger->debug(sprintf("Root directory '%s'", realpath($root_dir)));
|
2020-08-27 08:37:46 +02:00
|
|
|
|
2020-08-27 00:57:01 +02:00
|
|
|
$server = stream_socket_server(
|
|
|
|
$this->getListenAddress(),
|
2020-09-06 09:32:56 +02:00
|
|
|
$errno,
|
|
|
|
$errstr,
|
2020-08-27 00:57:01 +02:00
|
|
|
STREAM_SERVER_BIND|STREAM_SERVER_LISTEN,
|
|
|
|
$this->ssl_context
|
|
|
|
);
|
|
|
|
|
|
|
|
if (!$server) {
|
|
|
|
throw new \Exception("Error " . $errno . ": " . $errstr);
|
|
|
|
}
|
2020-09-04 07:27:04 +02:00
|
|
|
stream_set_blocking($server, true);
|
2020-08-27 00:57:01 +02:00
|
|
|
|
|
|
|
$name = stream_socket_get_name($server, false);
|
2020-09-03 22:59:02 +02:00
|
|
|
$this->logger->info(sprintf("Listening on %s://%s...", self::SCHEME, $name));
|
2020-08-27 00:57:01 +02:00
|
|
|
|
2020-09-04 07:27:04 +02:00
|
|
|
$this->connections = [];
|
|
|
|
$this->peers = [];
|
|
|
|
$this->metas = [];
|
|
|
|
$_write = null;
|
|
|
|
$_exception = null;
|
|
|
|
|
2020-08-27 00:57:01 +02:00
|
|
|
while (true) {
|
2020-09-04 07:27:04 +02:00
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* Intake connections
|
|
|
|
*
|
|
|
|
* @param resource $server
|
|
|
|
* @return bool
|
|
|
|
*/
|
|
|
|
public function intakeConnections($server): bool
|
2020-09-04 07:27:04 +02:00
|
|
|
{
|
2020-09-06 09:32:56 +02:00
|
|
|
if (!is_resource($server)) {
|
|
|
|
throw new \Exception("Stream server must be resource object");
|
|
|
|
}
|
|
|
|
|
2020-09-04 07:27:04 +02:00
|
|
|
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) {
|
2020-09-06 09:32:56 +02:00
|
|
|
# If the listener times out, it will run this and then start
|
|
|
|
# listening again
|
2020-09-04 07:27:04 +02:00
|
|
|
$this->pruneExpiredConnections();
|
2020-09-06 09:32:56 +02:00
|
|
|
return false;
|
2020-09-04 07:27:04 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
$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];
|
2020-09-06 09:32:56 +02:00
|
|
|
|
|
|
|
return true;
|
2020-09-04 07:27:04 +02:00
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* Handle a single peer connection (client request)
|
|
|
|
*
|
|
|
|
* @param resource $client
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function handlePeerConnection($client): void
|
2020-09-04 07:27:04 +02:00
|
|
|
{
|
|
|
|
$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'];
|
2020-09-06 09:32:56 +02:00
|
|
|
//print(Util::hexView($request_buffer));
|
2020-09-04 07:27:04 +02:00
|
|
|
//print("Length: " . mb_strlen($request_buffer) . "\n");
|
|
|
|
$this->logger->info("REQ: $request_buffer", ['client' => $peer]);
|
|
|
|
|
|
|
|
$request = new Request($request_buffer);
|
|
|
|
|
|
|
|
// Respond to client
|
2020-09-08 00:05:38 +02:00
|
|
|
$response = $this->handle($request);
|
2020-09-06 09:32:56 +02:00
|
|
|
$size = 0;
|
|
|
|
|
|
|
|
try {
|
|
|
|
$size = $response->send($client);
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
$this->logger->error($e->getMessage());
|
2020-08-27 00:57:01 +02:00
|
|
|
}
|
2020-09-04 07:27:04 +02:00
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
$time_end = microtime(true);
|
|
|
|
$this->logger->debug(
|
|
|
|
"RSP: " . trim($response->getHeader()),
|
|
|
|
['size' => $size, 'time' => $time_end - $this->metas[$peer]['time']['start']]
|
|
|
|
);
|
|
|
|
|
2020-09-04 07:27:04 +02:00
|
|
|
fclose($client);
|
|
|
|
$this->logger->debug("$peer Closed");
|
|
|
|
unset($this->connections[$peer]);
|
|
|
|
unset($this->metas[$peer]);
|
2020-08-27 00:57:01 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* Handle creating a response and sending it to a client
|
|
|
|
*
|
|
|
|
* @param Request $request
|
|
|
|
* @return Response
|
|
|
|
*/
|
2020-09-08 00:05:38 +02:00
|
|
|
public function handle(Request $request): Response
|
2020-08-27 08:37:46 +02:00
|
|
|
{
|
2020-09-06 09:32:56 +02:00
|
|
|
[$is_valid, $response] = $this->validateRequest($request);
|
2020-09-03 22:59:02 +02:00
|
|
|
if ($is_valid === false) {
|
2020-09-06 09:32:56 +02:00
|
|
|
// If not valid, just return the error response object created by
|
|
|
|
// the validator
|
2020-09-03 21:11:09 +02:00
|
|
|
return $response;
|
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
$statics_module = new Statics($this->config, $this->logger);
|
2020-09-08 00:05:38 +02:00
|
|
|
[$success, $response] = $statics_module->handle($request);
|
2020-08-27 08:37:46 +02:00
|
|
|
|
|
|
|
return $response;
|
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* 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
|
2020-09-04 07:27:04 +02:00
|
|
|
{
|
|
|
|
$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]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* 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
|
2020-09-03 22:59:02 +02:00
|
|
|
{
|
|
|
|
$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
|
2020-09-06 09:32:56 +02:00
|
|
|
if ($request->host != "127.0.0.1" && $request->host != "localhost"
|
|
|
|
&& $request->host != $this->config->hostname
|
|
|
|
) {
|
2020-09-03 22:59:02 +02:00
|
|
|
$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];
|
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* 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
|
2020-08-27 00:57:01 +02:00
|
|
|
{
|
2020-09-06 09:32:56 +02:00
|
|
|
if (strpos($errstr, "timed out") !== false) {
|
2020-08-27 00:57:01 +02:00
|
|
|
// Do nothing
|
2020-09-06 09:32:56 +02:00
|
|
|
return true;
|
2020-08-27 00:57:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// Something else happened.
|
2020-09-06 09:32:56 +02:00
|
|
|
$message = sprintf("Error while accepting connection %s: %s", $errno, $errstr);
|
|
|
|
$this->logger->error($message);
|
2020-08-27 00:57:01 +02:00
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
return true;
|
2020-08-27 00:57:01 +02:00
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* Get the listen address for the stream_server
|
|
|
|
*
|
|
|
|
* @return string
|
|
|
|
*/
|
|
|
|
public function getListenAddress(): string
|
2020-08-27 09:46:11 +02:00
|
|
|
{
|
2020-09-06 09:32:56 +02:00
|
|
|
return sprintf('tls://%s:%s', $this->config->host, $this->config->port);
|
2020-08-27 09:46:11 +02:00
|
|
|
}
|
|
|
|
|
2020-09-06 09:32:56 +02:00
|
|
|
/**
|
|
|
|
* Create SSL context for stream server
|
|
|
|
*
|
|
|
|
* @return resource
|
|
|
|
*/
|
2020-08-27 00:57:01 +02:00
|
|
|
public function createSslContext()
|
|
|
|
{
|
|
|
|
$context = stream_context_create();
|
|
|
|
|
|
|
|
// local_cert must be in PEM format
|
2020-08-31 08:04:44 +02:00
|
|
|
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);
|
2020-08-27 00:57:01 +02:00
|
|
|
|
|
|
|
stream_context_set_option($context, 'ssl', 'allow_self_signed', true);
|
|
|
|
stream_context_set_option($context, 'ssl', 'verify_peer', false);
|
|
|
|
|
|
|
|
return $context;
|
|
|
|
}
|
|
|
|
}
|