317 lines
10 KiB
PHP
317 lines
10 KiB
PHP
<?php
|
|
|
|
namespace Orbit;
|
|
|
|
use Monolog\Logger;
|
|
use Monolog\Handler\StreamHandler;
|
|
use ForceUTF8\Encoding;
|
|
|
|
class Server
|
|
{
|
|
public static $version = "0.3";
|
|
|
|
public $config;
|
|
public $cert;
|
|
public $timeout = 60;
|
|
|
|
private $ssl_context;
|
|
private $logger;
|
|
|
|
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");
|
|
}
|
|
|
|
$path = realpath($root_dir);
|
|
|
|
$this->logger->debug("Root directory '$path'");
|
|
|
|
var_dump($this->getListenAddress());
|
|
$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->logger->info("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);
|
|
//stream_socket_enable_crypto($server, true, STREAM_CRYPTO_METHOD_TLSv1_2_SERVER);
|
|
restore_error_handler();
|
|
|
|
if ($client) {
|
|
$time = ['start' => microtime(true)];
|
|
|
|
$this->logger->debug("$client_name Accepted");
|
|
$request_buffer = stream_get_line($client, 1026, "\r\n");
|
|
print($this->hexView($request_buffer));
|
|
print("Length: " . mb_strlen($request_buffer) . "\n");
|
|
$this->logger->info("REQ: $request_buffer", ["client" => $client_name]);
|
|
|
|
if (trim($request_buffer)) {
|
|
$request = new Request($request_buffer);
|
|
|
|
// Respond to client
|
|
$response = $this->handleResponse($request, $path);
|
|
$size = $response->send($client);
|
|
$time['end'] = microtime(true);
|
|
$this->logger->debug(
|
|
"RSP: " . trim($response->getHeader()),
|
|
['size' => $size, 'time' => $time['end'] - $time['start']]
|
|
);
|
|
}
|
|
|
|
fclose($client);
|
|
$this->logger->debug("$client_name Closed");
|
|
}
|
|
}
|
|
}
|
|
|
|
public function handleResponse($request, $dir)
|
|
{
|
|
$response = new 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 $response;
|
|
}
|
|
|
|
if ($request->host != "127.0.0.1" && $request->host != $this->config->hostname) {
|
|
$response->setStatus(Response::STATUS_PROXY_REQUEST_REFUSED);
|
|
$response->setMeta("Proxy error - invalid host");
|
|
return $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 $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 $response;
|
|
}
|
|
|
|
$resource_path = rtrim($dir, "/") . $request->path;
|
|
|
|
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 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;
|
|
}
|
|
}
|