diff --git a/README.md b/README.md index f5f6423..32120c9 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,71 @@ # 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 * `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 -## NEX - -Optimal to serve static files - -For security reasons, `next` server prevents any access to the hidden files (started with dot) +## Launch ### Start -Create as many servers as wanted by providing different `host` and `port` using optional arguments +Create as many servers as wanted by providing different `type`, `host`, `port` and other arguments. + +* 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 + +Simple example: ``` 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 ##### 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 * `host` - `127.0.0.1` by default -* `port` - `1900` by default -* `file` - index **filename** 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 -* `fail` - **filepath** that contain failure text or template (e.g. `error.gmi`), `fail` text by default -* `size` - limit request length in bytes, `1024` by default -* `dump` - query log, blank to disable, default: `[{time}] [{code}] {host}:{port} {path} {real} {size} bytes` - * `{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 +* `port` - depends of server `type` 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), enabled by default +* `time` - show file modification time as the alt text in directory listing, disabled by default +* `fail` - **absolute path** to the failure template file (e.g. `/path/to/error.gmi`), disabled by default +* `dump` - query log, enabled by default ### 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`) -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 +# /etc/systemd/system/next.service + [Unit] After=network.target @@ -62,7 +73,7 @@ After=network.target Type=simple User=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 StandardError=file:/home/next/error.log Restart=on-failure @@ -71,6 +82,6 @@ Restart=on-failure WantedBy=multi-user.target ``` -3. `sudo systemctl daemon-reload` - reload systemd configuration -4. `sudo systemctl enable next` - enable `next` service on system startup -5. `sudo systemctl start next` - start `next` server +* `systemctl daemon-reload` - reload systemd configuration +* `systemctl enable next` - enable service on system startup +* `systemctl start next` - start server diff --git a/composer.json b/composer.json index 0b71e42..b18e216 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "homepage": "https://github.com/yggverse/pulsar", "type": "project", "require": { - "yggverse/nex": "^1.1" + "cboden/ratchet": "^0.4.4" }, "license": "MIT", "autoload": { diff --git a/default.json b/default.json new file mode 100644 index 0000000..f14dbc6 --- /dev/null +++ b/default.json @@ -0,0 +1,5 @@ +{ + "host":"127.0.0.1", + "list":true, + "dump":true +} \ No newline at end of file diff --git a/src/Controller/Nex.php b/src/Controller/Nex.php new file mode 100644 index 0000000..56842d0 --- /dev/null +++ b/src/Controller/Nex.php @@ -0,0 +1,263 @@ +_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(); + } +} \ No newline at end of file diff --git a/src/Model/Environment.php b/src/Model/Environment.php new file mode 100644 index 0000000..d9863cb --- /dev/null +++ b/src/Model/Environment.php @@ -0,0 +1,84 @@ + $value) + { + $this->_config[$key] = (string) $value; + } + + foreach ($argv as $value) + { + if (preg_match('/^(?[^=]+)=(?.*)$/', $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; + } +} \ No newline at end of file diff --git a/src/Model/Filesystem.php b/src/Model/Filesystem.php new file mode 100644 index 0000000..5bd52fb --- /dev/null +++ b/src/Model/Filesystem.php @@ -0,0 +1,253 @@ +_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; + } +} \ No newline at end of file diff --git a/src/nex.php b/src/nex.php deleted file mode 100644 index dbbb162..0000000 --- a/src/nex.php +++ /dev/null @@ -1,355 +0,0 @@ -[^=]+)=(?.*)$/', $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; - } -); \ No newline at end of file diff --git a/src/server.php b/src/server.php new file mode 100644 index 0000000..a4bd7ee --- /dev/null +++ b/src/server.php @@ -0,0 +1,59 @@ +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 +}