Browse Source

implement multi-protocol async server based on ratchet library #1

main
yggverse 7 months ago
parent
commit
ec60ef5e5b
  1. 69
      README.md
  2. 2
      composer.json
  3. 5
      default.json
  4. 263
      src/Controller/Nex.php
  5. 84
      src/Model/Environment.php
  6. 253
      src/Model/Filesystem.php
  7. 355
      src/nex.php
  8. 59
      src/server.php

69
README.md

@ -1,60 +1,71 @@
# next # next
PHP 8 Server for [NEX Protocol](https://nightfall.city/nex/info/specification.txt), based on the [nex-php](https://github.com/YGGverse/nex-php) library PHP 8 server for different protocols, based on [Ratchet](https://github.com/ratchetphp/Ratchet) asynchronous socket library.
## Features
* Asynchronous connections
* Multi-host
* Multiple protocols:
* [x] [NEX](https://nightfall.city/nex/info/specification.txt)
* [ ] [Gemini](https://geminiprotocol.net)
* Detailed event log
* Optional:
* directory listing navigation with safe filesystem access
* custom index file names
* custom failure page
* Flexible server configuration by environment arguments
## Install ## Install
* `git clone https://github.com/YGGverse/next.git` * `git clone https://github.com/YGGverse/next.git`
* `cd next` - navigate into the server directory * `cd next` - navigate into the project directory
* `composer update` - grab latest dependencies * `composer update` - grab latest dependencies
## NEX ## Launch
Optimal to serve static files ### Start
For security reasons, `next` server prevents any access to the hidden files (started with dot) Create as many servers as wanted by providing different `type`, `host`, `port` and other arguments.
### Start * for security reasons, `next` server prevents any access to the hidden files (started with dot).\
* also, clients can't access any data out the `root` path, defined on server startup
Create as many servers as wanted by providing different `host` and `port` using optional arguments Simple example:
``` bash ``` bash
php src/nex.php host=127.0.0.1 port=1900 path=/target/dir php src/server.php type=nex host=127.0.0.1 port=1900 root=/target/dir
``` ```
* `host` and `port` is optional, read [arguments documentation](#arguments) for details!
#### Arguments #### Arguments
##### Required ##### Required
* `path` - **absolute path** to the public directory * `type` - server protocol, supported options:
* `nex` - [NEX Protocol](https://nightfall.city/nex/info/specification.txt)
* `root` - **absolute path** to the public directory
##### Optional ##### Optional
* `host` - `127.0.0.1` by default * `host` - `127.0.0.1` by default
* `port` - `1900` by default * `port` - depends of server `type` by default
* `file` - index **file name** that server try to open on directory path requested, disabled by default * `file` - index **file name** that server try to open on directory path requested, disabled by default
* `list` - show content listing in the requested directory (when index file not found), `yes` by default * `list` - show content listing in the requested directory (when index file not found), enabled by default
* `fail` - **filepath** that contain failure text or template (e.g. `error.gmi`), `fail` text by default * `time` - show file modification time as the alt text in directory listing, disabled by default
* `size` - limit request length in bytes, `1024` by default * `fail` - **absolute path** to the failure template file (e.g. `/path/to/error.gmi`), disabled by default
* `dump` - query log, blank to disable, default: `[{time}] [{code}] {host}:{port} {path} {real} {size} bytes` * `dump` - query log, enabled by default
* `{time}` - event time in `c` format
* `{code}` - formal response code: `1` - found, `0` - not found
* `{host}` - peer host
* `{port}` - peer port
* `{path}` - path requested
* `{real}` - **realpath** returned
* `{size}` - response size in bytes
### Autostart ### Autostart
Launch server as the `systemd` service #### systemd
Following example mean you have `next` server installed into home directory of `next` user (`useradd -m next`) Following example mean you have `next` server installed into home directory of `next` user (`useradd -m next`)
1. `mkdir /home/next/public` - make sure you have created public folder
2. `sudo nano /etc/systemd/system/next.service` - create new service:
``` next.service ``` next.service
# /etc/systemd/system/next.service
[Unit] [Unit]
After=network.target After=network.target
@ -62,7 +73,7 @@ After=network.target
Type=simple Type=simple
User=next User=next
Group=next Group=next
ExecStart=/usr/bin/php /home/next/next/src/nex.php path=/home/next/public ExecStart=/usr/bin/php /home/next/next/src/server.php type=nex root=/home/next/public
StandardOutput=file:/home/next/debug.log StandardOutput=file:/home/next/debug.log
StandardError=file:/home/next/error.log StandardError=file:/home/next/error.log
Restart=on-failure Restart=on-failure
@ -71,6 +82,6 @@ Restart=on-failure
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
3. `sudo systemctl daemon-reload` - reload systemd configuration * `systemctl daemon-reload` - reload systemd configuration
4. `sudo systemctl enable next` - enable `next` service on system startup * `systemctl enable next` - enable service on system startup
5. `sudo systemctl start next` - start `next` server * `systemctl start next` - start server

2
composer.json

@ -5,7 +5,7 @@
"homepage": "https://github.com/yggverse/pulsar", "homepage": "https://github.com/yggverse/pulsar",
"type": "project", "type": "project",
"require": { "require": {
"yggverse/nex": "^1.1" "cboden/ratchet": "^0.4.4"
}, },
"license": "MIT", "license": "MIT",
"autoload": { "autoload": {

5
default.json

@ -0,0 +1,5 @@
{
"host":"127.0.0.1",
"list":true,
"dump":true
}

263
src/Controller/Nex.php

@ -0,0 +1,263 @@
<?php
namespace Yggverse\Next\Controller;
use \Ratchet\MessageComponentInterface;
class Nex implements MessageComponentInterface
{
private \Yggverse\Next\Model\Environment $_environment;
private \Yggverse\Next\Model\Filesystem $_filesystem;
public function __construct(
\Yggverse\Next\Model\Environment $environment,
\Yggverse\Next\Model\Filesystem $filesystem
) {
// Init environment
$this->_environment = $environment;
// Init filesystem
$this->_filesystem = $filesystem;
// Check port is defined
if (!$this->_environment->get('port'))
{
// Set protocol defaults
$this->_environment->set('port', 1900);
}
// Dump event
if ($this->_environment->get('dump'))
{
print(
str_replace(
[
'{time}',
'{host}',
'{port}',
'{root}'
],
[
(string) date('c'),
(string) $this->_environment->get('host'),
(string) $this->_environment->get('port'),
(string) $this->_filesystem->root()
],
_('[{time}] [init] server started at {host}:{port}{root}')
) . PHP_EOL
);
}
}
public function onOpen(
\Ratchet\ConnectionInterface $connection
) {
// Dump event
if ($this->_environment->get('dump'))
{
print(
str_replace(
[
'{time}',
'{host}',
'{crid}'
],
[
(string) date('c'),
(string) $connection->remoteAddress,
(string) $connection->resourceId
],
_('[{time}] [open] incoming connection {host}#{crid}')
) . PHP_EOL
);
}
}
public function onMessage(
\Ratchet\ConnectionInterface $connection,
$request
) {
// Define response
$response = null;
// Filter request
$request = trim(
$request
);
// Build absolute realpath
$realpath = $this->_filesystem->absolute(
$request
);
// Make sure realpath valid to continue
if ($this->_filesystem->valid($realpath))
{
// Route
switch (true)
{
// File request
case $file = $this->_filesystem->file($realpath):
// Return file content
$response = $file;
break;
// Directory request
case $list = $this->_filesystem->list($realpath):
// Try index file on defined
if ($index = $this->_filesystem->file($realpath . $this->_environment->get('file')))
{
// Return index file content
$response = $index;
}
// Listing enabled
else if ($this->_environment->get('list'))
{
// FS map
$line = [];
foreach ($list as $item)
{
// Build gemini text link
$link = ['=>'];
if ($item['name'])
{
$link[] = $item['file'] ? $item['name']
: $item['name'] . '/';
}
if ($item['time'] && $this->_environment->get('time'))
{
$link[] = date('Y-m-d', $item['time']);
}
// Append link to the new line
$line[] = implode(' ', $link);
}
// Merge lines to response
$response = implode(
PHP_EOL,
$line
);
}
break;
}
}
// Dump event
if ($this->_environment->get('dump'))
{
// Print debug from template
print(
str_ireplace(
[
'{time}',
'{host}',
'{crid}',
'{path}',
'{real}',
'{size}'
],
[
(string) date('c'),
(string) $connection->remoteAddress,
(string) $connection->resourceId,
(string) str_replace(
'%',
'%%',
$request
),
(string) str_replace(
'%',
'%%',
$realpath
),
(string) mb_strlen(
$response
)
],
_('[{time}] [message] incoming connection {host}#{crid} "{path}" > "{real}" {size} bytes')
) . PHP_EOL
);
}
// Noting to return?
if (empty($response))
{
// Try failure file on defined
if ($fail = $this->_filesystem->file($this->_environment->get('fail')))
{
$response = $fail;
}
}
// Send response
$connection->send(
$response
);
// Disconnect
$connection->close();
}
public function onClose(
\Ratchet\ConnectionInterface $connection
) {
// Dump event
if ($this->_environment->get('dump'))
{
print(
str_replace(
[
'{time}',
'{host}',
'{crid}'
],
[
(string) date('c'),
(string) $connection->remoteAddress,
(string) $connection->resourceId
],
_('[{time}] [close] incoming connection {host}#{crid}')
) . PHP_EOL
);
}
}
public function onError(
\Ratchet\ConnectionInterface $connection,
\Exception $exception
) {
// Dump event
if ($this->_environment->get('dump'))
{
print(
str_replace(
[
'{time}',
'{host}',
'{crid}',
'{info}'
],
[
(string) date('c'),
(string) $connection->remoteAddress,
(string) $connection->resourceId,
(string) $exception->getMessage()
],
_('[{time}] [error] incoming connection {host}#{crid} reason: {info}')
) . PHP_EOL
);
}
// Disconnect
$connection->close();
}
}

84
src/Model/Environment.php

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Yggverse\Next\Model;
class Environment
{
private array $_config;
public function __construct(
array $argv,
array $default = []
) {
foreach ($default as $key => $value)
{
$this->_config[$key] = (string) $value;
}
foreach ($argv as $value)
{
if (preg_match('/^(?<key>[^=]+)=(?<value>.*)$/', $value, $argument))
{
$this->_config[mb_strtolower($argument['key'])] = (string) $argument['value'];
}
}
}
public function get(
string $key
): mixed
{
$key = mb_strtolower(
$key
);
return isset($this->_config[$key]) ? $this->_config[$key]
: null;
}
public function set(
string $key,
string $value,
bool $semantic = true
): void
{
if ($semantic)
{
$_value = mb_strtolower(
$value
);
switch (true)
{
case in_array(
$_value,
[
'1',
'yes',
'true',
'enable'
]
): $value = true;
break;
case in_array(
$_value,
[
'0',
'no',
'null',
'false',
'disable'
]
): $value = false;
break;
}
}
$this->_config[mb_strtolower($key)] = $value;
}
}

253
src/Model/Filesystem.php

@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace Yggverse\Next\Model;
class Filesystem
{
private string $_root;
public function __construct(?string $path)
{
// Require path value to continue
if (empty($path))
{
throw new \Exception(
_('root path required!')
);
}
// Require absolute path
if (!str_starts_with($path, DIRECTORY_SEPARATOR))
{
throw new \Exception(
_('root path not absolute!')
);
}
// Exclude symlinks and relative entities, append slash
if (!$realpath = $this->_realpath($path))
{
throw new \Exception(
_('could not build root realpath!')
);
}
// Root must be directory
if (!is_dir($realpath))
{
throw new \Exception(
_('root path is not directory!')
);
}
// Check root path does not contain hidden context
if (str_contains($realpath, DIRECTORY_SEPARATOR . '.'))
{
throw new \Exception(
_('root path must not contain hidden context!')
);
}
// Done!
$this->_root = $realpath;
}
public function root(): string
{
return $this->_root;
}
public function file(?string $realpath): ?string
{
if (!$this->valid($realpath))
{
return null;
}
if (!is_file($realpath))
{
return null;
}
return file_get_contents(
$realpath
);
}
public function list(
?string $realpath,
string $sort = 'name',
int $order = SORT_ASC,
int $method = SORT_STRING | SORT_NATURAL | SORT_FLAG_CASE
): ?array
{
// Validate requested path
if (!$this->valid($realpath))
{
return null;
}
// Make sure requested path is directory
if (!is_dir($realpath))
{
return null;
}
// Begin list builder
$directories = [];
$files = [];
foreach ((array) scandir($realpath) as $name)
{
// Skip system locations
if (empty($name) || $name == '.')
{
continue;
}
// Build destination path
if (!$path = $this->_realpath($realpath . $name))
{
continue;
}
// Validate destination path
if (!$this->valid($path))
{
continue;
}
// Context
switch (true)
{
case is_dir($path):
$directories[] =
[
'file' => false,
'name' => $name,
'path' => $path,
'time' => filemtime(
$path
)
];
break;
case is_file($path):
$files[] =
[
'file' => true,
'name' => $name,
'path' => $path,
'time' => filemtime(
$path
)
];
break;
}
}
// Sort order
array_multisort(
array_column(
$directories,
$sort
),
$order,
$method,
$directories
);
// Sort files by name ASC
array_multisort(
array_column(
$directories,
$sort
),
$order,
$method,
$directories
);
// Merge list
return array_merge(
$directories,
$files
);
}
public function valid(?string $realpath): bool
{
if (empty($realpath))
{
return false;
}
if ($realpath != $this->_realpath($realpath))
{
return false;
}
if (!str_starts_with($realpath, $this->_root))
{
return false;
}
if (str_contains($realpath, DIRECTORY_SEPARATOR . '.'))
{
return false;
}
if (!is_readable($realpath))
{
return false;
}
return true;
}
// Return absolute realpath with root constructed
public function absolute(?string $path): ?string
{
if (!$realpath = $this->_realpath($this->_root . $path))
{
return null;
}
return $realpath;
}
// PHP::realpath extension appending slash to dir paths
private function _realpath(?string $path): ?string
{
if (empty($path))
{
return null;
}
if (!$realpath = realpath($path))
{
return null;
}
if (!is_readable($realpath))
{
return null;
}
if (is_dir($realpath))
{
$realpath = rtrim(
$realpath,
DIRECTORY_SEPARATOR
) . DIRECTORY_SEPARATOR;
}
return $realpath;
}
}

355
src/nex.php

@ -1,355 +0,0 @@
<?php
// Load dependencies
require_once __DIR__ .
DIRECTORY_SEPARATOR . '..'.
DIRECTORY_SEPARATOR . 'vendor' .
DIRECTORY_SEPARATOR . 'autoload.php';
// Parse startup arguments
foreach ((array) $argv as $item)
{
if (preg_match('/^(?<key>[^=]+)=(?<value>.*)$/', $item, $argument))
{
switch ($argument['key'])
{
case 'host':
define(
'NEXT_HOST',
(string) $argument['value']
);
break;
case 'port':
define(
'NEXT_PORT',
(int) $argument['value']
);
break;
case 'path':
$path = rtrim(
(string) $argument['value'],
DIRECTORY_SEPARATOR
) . DIRECTORY_SEPARATOR;
if (!str_starts_with($path, DIRECTORY_SEPARATOR))
{
print(
_('absolute path required')
) . PHP_EOL;
exit;
}
if (!is_dir($path) || !is_readable($path))
{
print(
_('path not accessible')
) . PHP_EOL;
exit;
}
define(
'NEXT_PATH',
(string) $path
);
break;
case 'file':
define(
'NEXT_FILE',
(string) $argument['value']
);
break;
case 'fail':
$fail = (string) $argument['value'];
if (!str_starts_with($fail, DIRECTORY_SEPARATOR))
{
print(
_('absolute path required')
) . PHP_EOL;
exit;
}
if (!is_file($fail) || !is_readable($fail))
{
print(
_('fail template not accessible')
) . PHP_EOL;
exit;
}
define(
'NEXT_FAIL',
(string) file_get_contents(
$fail
)
);
break;
case 'list':
define(
'NEXT_LIST',
in_array(
mb_strtolower(
(string) $argument['value']
),
[
'true',
'yes',
'1'
]
)
);
break;
case 'size':
define(
'NEXT_SIZE',
(int) $argument['value']
);
break;
case 'dump':
define(
'NEXT_DUMP',
(string) $argument['value']
);
break;
}
}
}
// Validate required arguments and set optional defaults
if (!defined('NEXT_HOST')) define('NEXT_HOST', '127.0.0.1');
if (!defined('NEXT_PORT')) define('NEXT_PORT', 1900);
if (!defined('NEXT_PATH'))
{
print(
_('path required')
) . PHP_EOL;
exit;
}
if (!defined('NEXT_FILE')) define('NEXT_FILE', false);
if (!defined('NEXT_LIST')) define('NEXT_LIST', true);
if (!defined('NEXT_SIZE')) define('NEXT_SIZE', 1024);
if (!defined('NEXT_FAIL')) define('NEXT_FAIL', 'fail');
if (!defined('NEXT_DUMP')) define('NEXT_DUMP', '[{time}] [{code}] {host}:{port} {path} {real} {size} bytes');
// Init server
$server = new \Yggverse\Nex\Server(
NEXT_HOST,
NEXT_PORT,
NEXT_SIZE
);
$server->start(
function (
string $request,
string $connect
): ?string
{
// Define response
$response = null;
// Filter request
$request = trim(
$request
);
$request = empty($request) ? '/' : $request;
// Build realpath
$realpath = realpath(
NEXT_PATH .
urldecode(
filter_var(
$request,
FILTER_SANITIZE_URL
)
)
);
// Make sure directory path ending with slash
if (is_dir($realpath))
{
$realpath = rtrim(
$realpath,
DIRECTORY_SEPARATOR
) . DIRECTORY_SEPARATOR;
}
// Validate realpath exists, started with path defined and does not contain hidden entities
if ($realpath && str_starts_with($realpath, NEXT_PATH) && !str_contains($realpath, DIRECTORY_SEPARATOR . '.'))
{
// Try directory
if (is_dir($realpath))
{
// Try index file on enabled
if (NEXT_FILE && file_exists($realpath . NEXT_FILE) && is_readable($realpath . NEXT_FILE))
{
// Update realpath returned on default file response
$realpath = $realpath . NEXT_FILE;
$response = file_get_contents(
$realpath
);
}
// Try directory listing on enabled
else if (NEXT_LIST)
{
$directories = [];
$files = [];
foreach ((array) scandir($realpath) as $filename)
{
// Process system entities
if (str_starts_with($filename, '.'))
{
// Parent navigation
if ($filename == '..' && $parent = realpath($realpath . $filename))
{
$parent = rtrim(
$parent,
DIRECTORY_SEPARATOR
) . DIRECTORY_SEPARATOR;
if (str_starts_with($parent, NEXT_PATH))
{
$directories[$filename] = '=> ../';
}
}
continue; // skip everything else
}
// Directory
if (is_dir($realpath . $filename))
{
if (is_readable($realpath . $filename))
{
$directories[$filename] = sprintf(
'=> %s/',
urlencode(
$filename
)
);
}
continue;
}
// File
if (is_readable($realpath . $filename))
{
$files[$filename] = sprintf(
'=> %s',
urlencode(
$filename
)
);
}
}
// Sort by keys ASC
ksort(
$directories,
SORT_STRING | SORT_FLAG_CASE | SORT_NATURAL
);
ksort(
$files,
SORT_STRING | SORT_FLAG_CASE | SORT_NATURAL
);
// Merge items
$response = implode(
PHP_EOL,
array_merge(
$directories,
$files
)
);
}
}
// Try file
else
{
$response = file_get_contents(
$realpath
);
}
}
// Dump request on enabled
if (NEXT_DUMP)
{
// Build connection URL #72811
$url = sprintf(
'nex://%s',
$connect
);
// Print dump from template
printf(
str_ireplace(
[
'{time}',
'{code}',
'{host}',
'{port}',
'{path}',
'{real}',
'{size}'
],
[
(string) date('c'),
(string) (int) is_string($response),
(string) parse_url($url, PHP_URL_HOST),
(string) parse_url($url, PHP_URL_PORT),
(string) str_replace('%', '%%', $request),
(string) str_replace('%', '%%', empty($realpath) ? '!' : $realpath),
(string) mb_strlen((string) $response)
],
NEXT_DUMP
) . PHP_EOL
);
}
// Send response
return is_string($response) ? $response : NEXT_FAIL;
}
);

59
src/server.php

@ -0,0 +1,59 @@
<?php
// Load dependencies
require_once __DIR__ .
DIRECTORY_SEPARATOR . '..'.
DIRECTORY_SEPARATOR . 'vendor' .
DIRECTORY_SEPARATOR . 'autoload.php';
// Init environment
$environment = new \Yggverse\Next\Model\Environment(
$argv,
json_decode(
file_get_contents(
__DIR__ .
DIRECTORY_SEPARATOR . '..'.
DIRECTORY_SEPARATOR . 'default.json'
),
true
)
);
// Init filesystem
$filesystem = new \Yggverse\Next\Model\Filesystem(
$environment->get('path')
);
// Start server
try
{
switch ($environment->get('type'))
{
case 'nex':
$server = \Ratchet\Server\IoServer::factory(
new \Yggverse\Next\Controller\Nex(
$environment,
$filesystem
),
$environment->get('port'),
$environment->get('host')
);
$server->run();
break;
default:
throw new \Exception(
_('valid server type required!')
);
}
}
// Show help
catch (\Exception $exception)
{
// @TODO
}
Loading…
Cancel
Save