orbit/src/Orbit/Server.php

418 lines
13 KiB
PHP

<?php
namespace Orbit;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use ForceUTF8\Encoding;
class Server
{
const SCHEME = "gemini";
public static $version = "0.3";
public $config;
public $cert;
public $timeout = 60;
private $ssl_context;
private $logger;
private $connections = []; // Incoming client connections
private $peers = []; // Client connections to read from
private $metas = []; // Meta data for each client connection
private $real_root_dir;
public function __construct(Config $config = null, Cert $cert = null, Logger $logger = null)
{
if ($config == null) {
$this->config = new Config();
} else {
$this->config = $config;
}
if ($logger !== null) {
$this->setLogger($logger);
} else {
$this->getLogger();
}
if ($cert == null) {
$this->cert = new Cert($config, $this->getLogger());
} else {
$this->cert = $cert;
}
$this->timeout = ini_get("default_socket_timeout");
$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($root_dir = null)
{
if (null == $root_dir) {
$root_dir = $this->config->root_dir;
}
if (!is_dir($root_dir)) {
throw new \Exception("Error: Root directory '$root_dir' not a directory");
}
$this->real_root_dir = realpath($root_dir);
$this->logger->debug(sprintf("Root directory '%s'", $this->real_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);
}
}
}
}
public function intakeConnections($server)
{
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) {
$this->pruneExpiredConnections();
return;
}
$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];
}
public function handlePeerConnection($client)
{
$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($this->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->handleResponse($request);
$size = $response->send($client);
if ($size !== false) {
$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]);
}
}
public function handleResponse($request)
{
list($is_valid, $response) = $this->validateRequest($request);
if ($is_valid === false) {
return $response;
}
$resource_path = rtrim($this->real_root_dir, "/") . $request->path;
// Check if within the server root
// Realpath will translate any '..' in the path
$realpath = realpath($resource_path);
if ($realpath && strpos($realpath, $this->real_root_dir) !== 0) {
$response->setStatus(Response::STATUS_PERMANENT_FAILURE);
$response->setMeta("Invalid location");
return $response;
}
if (is_dir($resource_path)) {
// If missing the final slash, issue a redirect
if ($resource_path[-1] != "/") {
$response->setStatus(Response::STATUS_REDIRECT_PERMANENT);
$response->setMeta($request->getUrlAppendPath('/'));
return $response;
}
// Check if index file exists
if (file_exists($resource_path . DIRECTORY_SEPARATOR . $this->config->index_file)) {
$resource_path = $resource_path . DIRECTORY_SEPARATOR . $this->config->index_file;
} else {
if (!$this->config->enable_directory_index) {
$response->setStatus(Response::STATUS_BAD_REQUEST);
$response->setMeta('Path not available');
return $response;
} else {
$response->setStatus(Response::STATUS_SUCCESS);
$response->setMeta('text/gemini');
$response->setBody($this->makeDirectoryIndex($resource_path, $dir));
return $response;
}
}
}
// File exists and is world readable
if (file_exists($resource_path) && (fileperms($resource_path) & 0x0004)) {
$response->setStatus(Response::STATUS_SUCCESS);
$pathinfo = pathinfo($resource_path);
if (!isset($pathinfo['extension'])) {
$response->setStatus(Response::STATUS_TEMPORARY_FAILURE);
$response->setMeta('Error reading resource');
return $response;
}
if ($pathinfo['extension'] == 'gmi' || $pathinfo['extension'] == 'gemini') {
$response->setMeta('text/gemini');
} elseif ($pathinfo['extension'] == 'md' || $pathinfo['extension'] == 'markdown') {
$response->setMeta('text/gemini');
} else {
$response->setMeta(mime_content_type($resource_path));
}
$response->setStaticFile($resource_path);
} else {
$response->setStatus(Response::STATUS_NOT_FOUND);
$response->setMeta('Not found!');
}
return $response;
}
public function pruneExpiredConnections()
{
$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]);
}
}
}
public function validateRequest($request)
{
$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];
}
public function log($level, $message, $context = [])
{
$this->getLogger()->log($level, $message, $context);
}
public function onWarning($id, $message)
{
if (strpos($message, "timed out") !== false) {
// Do nothing
return;
}
// Something else happened.
$error = sprintf("Error while accepting connection %s: %s", $id, $message);
$this->logger->error($error);
}
public function getListenAddress()
{
return sprintf('tls://%s:%s', $this->config->host, $this->config->port);
}
public function makeDirectoryIndex($path, $root)
{
$files = glob($path . "*");
$body = "# Directory listing " . str_replace($root, '', $path) . "\n\n";
$body .= "=> " . str_replace($root, '', dirname($path)) . " ..\n";
foreach ($files as $file) {
$relative_path = str_replace($path, '', $file);
$is_dir = false;
if (is_dir($file)) {
$is_dir = true;
$size = '';
} else {
$size = filesize($file);
}
$body .= sprintf(
"=> %s%s %s%s%s\n",
urlencode($relative_path),
($is_dir ? '/' : ''),
$relative_path,
($is_dir ? '/' : ''),
($size ? " ($size)" : '')
);
}
return $body;
}
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;
}
/**
* View hex chars of string
*
* Outputs a listing of hexidecimal values in 16 byte rows
*
* @param mixed $text Input text
* @return string
*/
private 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;
}
}