Запускаем свой Gopher-сервер

В этой статье мы вам расскажем и покажем как запустить свой Gopher сервер, а также расскажем что такое Gopher и для чего это надо. Мало кто про Gopher слышал, особенно с учетом того, что активных таких серверов сейчас мало и мало кто этим пользуется, но честно говоря, нас это не смущает, а скорее наоборот привлекает и интересует.

И в силу этого, а также ряда иных причин, мы бы хотели вам рассказать об этом простом протоколе.

Что же такое Gopher ?

Gopher — это такой сетевой протокол распределенного поиска и передачи документов. Протокол был широко распространен в Интернете до 1993 и сейчас уже практически вытеснен более современным HTTP. Gopher был задуман как средство предоставления доступа к документам и имеет строгую иерархическую структуру, но обладает меньшими возможностями по сравнению с используемыми в настоящее время протоколами.

Казалось бы, протокол морально устарел и медленно умирает, однако, в настоящее время количество серверов, поддерживающих Gopher медленно растет. И тут дело не только в том, что народу нечем заняться, а в том что сам по себе Gopher устроен очень просто и правильно написанный сервер можно запустить практически на любом железе.

Протокол действительно очень прост, в чем легко убедиться, заглянув в описание его стандарта, который носит наименование RFC 1436. Сам стандарт достаточно короткий и описывает весь необходимый функционал, но мы расскажем некоторый теоретический минимум, а потом покажем как запустить свой Gopher через Yggdrasil.

Протокол Gopher является клиент-серверным протоколом и обслуживает 70 порт TCP. Порт практически неизменен для реализаций Gopher и редко можно увидеть нестандартный его номер. Сам протокол, в основном, текст-ориентированный и не имеет никакого шифрования, что безусловно облегчает реализацию.

Примечание автора. Отсутствие шифрования может напрягать в современное время, особенно, с учетом множества громких случаев с описанием различных кибератак и киберугроз. Но, стоит помнить, что Gopher разработан еще в 1991 и о шифровании тогда даже не думали. Однако, никто не мешает запустить Gopher-сервер в защищенной сети с доверенными узлами и тем более, никто не рекомендует вам через Gopher раздавать файлы с чувствительной информацией.

Работает протокол так: клиент отсылает серверу некоторый запрос, на который сервер отвечает и тут же закрывает соединение. Запрос клиента в свою очередь может содержать либо запрос первой страницы с Gopher-сервера, либо запрос на нужный документ (файл). В первом случае отсылается пустая строка, а во втором — путь до документа на сервере. И то и другое заканчиваются символами перевода строки (в данном случае, это CR и LF, идущие друг за другом и которые в байтовом виде выглядят так: 0x0d и 0x0a).

В случае первой страницы обычно отсылается содержимое основного каталога, на «раздачу» которого настроен Gopher-сервер. Внутри основного каталога, содержится описание того, что можно получить из него, а это могут быть файлы и/или другие каталоги.

Как говорилось ранее, протокол иерархичен, а это значит, что содержимое в нем хранится с учетом вложенности и разных уровней (каталогов). Также это означает и то, что Gopher определяет некоторые типы содержимого, в зависимости от того, чем оно является по своей природе: файлом определенного типа, каталогом или ссылкой на ресурс. На практике, это означает, что после запроса на каталог (не важно основной или вложенный в основной), идет описание того, что он вмещает в себя в виде записей с определенным количеством полей.

Таких полей в описании всего 5:

  • Символ типа содержимого (это либо цифра от 0 до 9, либо буква от a до Z);
  • Строка описания;
  • Путь (обычно предполагается, что он описывает расположение в файловой системе);
  • Доменное имя сервера;
  • Порт.

и все это передается в текстовом виде. Стоит отметить, что первое поле никак не отделено от второго, а все остальные отделяются друг от друга символом табуляции (TAB, код символа 0x09), а последнее пятое поле заканчивается символом перевода строки (уже упоминавшиеся CR и LF).

Примечание автора. Как мы поняли, если указать это поле с доменным именем не текущего сервера, то это будет означать, фактическую ссылку на другой Gopher-сервер.

На этом описание протокола почти закончено, кроме одного момента — тип содержимого, о котором мы расскажем прямо сейчас.

Тип содержимого в Gopher указывается одним символом и тут есть один нюанс: за все время существования Gopher было выделено несколько основных типов содержимого (которые сейчас неформально называют каноничными, т.е те, которые присутствуют в стандарте RFC 1436), но потом появилось расширение Gopher+ (добавлено еще несколько типов содержимого) и также были локальные эксперименты, которые добавляли что-то свое, расширяя существующий стандарт.

Итак, основные типы содержимого:

  • 0 = текстовый файл
  • 1 = каталог
  • 2 = сервер имён CSO
  • 3 = ошибка
  • 4 = файл Macintosh в формате BinHex
  • 5 = архив ZIP
  • 6 = файл UNIX, закодированный UUE
  • 7 = поисковый сервер
  • 8 = ссылка на telnet-сессию
  • 9 = бинарный файл
  • + = запасной сервер
  • h = файл в формате HTML
  • g = графический файл в формате GIF
  • i = информационный текст
  • I = графический файл (отображение определяется клиентом)
  • T = ссылка на сессию TN3270

Стоит заметить, что некоторые типы весьма необычны или отсутствуют в современных реалиях, однако, сейчас они получили либо другую интерпретацию, либо просто не обрабатываются текущими реализациями в виду отсутствия в этом необходимости (это типы содержимого 4, 8и T, которые уже не актуальны, а тип 5 вообще предназначался для файлов DOS, но сейчас применяется для указания того, что используется архив).

Теперь когда все описано, перейдем к нашей реализации на D.

Сразу скажем, что реализация не полная, и вот почему: не реализован полнотекстовый поиск (честно говоря, не нашли материала, как это устроено), а также опущена реализация некоторых каноничных типов содержимого (причина указана выше) и некоторых неканоничных, но используемых (сложно понять, что именно поддерживается сейчас и как это сделать, чтобы не сломать совместимость со старыми версиями).

Для того, чтобы реализовать свой сервер для Gopher определим сначала перечисления с определением разделителей в запросах/ответах и также типы содержимого:

enum GOPHER_DELIMETER
{
	CR   = 0x0d,
	LF   = 0x0a,
	TAB  = 0x09
}

enum GOPHER_CONTENT : ubyte
{
	// canonical types
	TEXT_FILE         =  '0',
	DIRECTORY         =  '1' ,
	CCSO_SERVER       =  '2',
	ERROR             =  '3',
	BINHEX_FILE       =  '4',
	DOS_FILE          =  '5',
	UUE_FILE	      =  '6',
	SEARCH            =  '7',
	TELNET            =  '8',
	BINARY_FILE       =  '9',
	DUPLICATE_SERVER  =  '+', 
	GIF_FILE		  =  'g',
	IMAGE_FILE        =  'I',
	TELNET_3270       =  'T',
	
	// gopher+
	BITMAP_IMAGE      =  ':',
	MOVIE_FILE        =  ';',
	SOUND		      =  '<',
	
	// non-canonical types
	DOC_FILE          =  'd',
	HTML_FILE         =  'h',
	INFORMATION       =  'i',
	SOUND_FILE        =  's'
}

После чего создадим функцию для формирования в байтовом представлении ответа сервера на основании поданного массива строк с описанием нужным полей, а также типа содержимого:

auto createResponse(GOPHER_CONTENT content, string[] requestArguments...)
{
	ubyte[] response;
	
	response ~= cast(ubyte) content;
	
	foreach (r; requestArguments[0..$-1])
	{
		response ~= cast(ubyte[]) r;
		response ~= GOPHER_DELIMETER.TAB;
	}
	
	response ~= requestArguments[$-1];
	response ~= GOPHER_DELIMETER.CR;
	response ~= GOPHER_DELIMETER.LF;
	
	return response;
}

Условимся, что наш сервер будет через Gopher предоставлять доступ к некоторой указанной пользователем папке по указанному сетевому адресу и на указанном порту, тогда остается лишь пройтись по папке, определить на основании того, что она в себе содержит, тип(ы) содержимого и отдать это в виде набора строк, каждая из которых оканчивается на CR LF:

auto createMapFromFS(string path, string server, string port)
{
	import std.file;
	import std.path;
	import std.string;
	
	ubyte[] gopherMap;
	
	foreach (entry; dirEntries(path, SpanMode.shallow))
	{
		auto name = entry.name;
		
		if (entry.isDir)
		{
			gopherMap ~= createResponse(GOPHER_CONTENT.DIRECTORY, name, name, server, port);
		}
		else
		{
			GOPHER_CONTENT type;
			auto extension = name.extension.toLower;
			
			switch (extension)
			{
				case ".c", ".d", ".txt":
					type = GOPHER_CONTENT.TEXT_FILE;
					break;
				case ".uue":
					type = GOPHER_CONTENT.UUE_FILE;
					break;
				case ".bmp", ".jpg", ".jpeg", ".pgm", ".png", ".ppm", ".tiff":
					type = GOPHER_CONTENT.IMAGE_FILE;
					break;
				case ".gif":
					type = GOPHER_CONTENT.GIF_FILE;
					break;
				case ".gz", ".zip", ".rar", ".tar.gz":
					type = GOPHER_CONTENT.DOS_FILE;
					break;
				default:
					type = GOPHER_CONTENT.BINARY_FILE;
					break;
			}
			
			gopherMap ~= createResponse(type, baseName(name), name, server, port);
		}
	}
	
	return gopherMap;
}

Работает просто: если при проходе нам попалась папка, то тогда присваиваем ей тип содержимого 1 и отдаем строку с ее именем и полным путем в файловой системе; если попался файл, то определяем его тип по расширению, а на основании пути создаем имя файла — и точно также как и в случае с папкой, отдаем содержимое в виде строки с типом, описанием и полным путем в файловой системе.

Получилось так, что у нас уже есть почти все что нужно для реализации, кроме сетевой части. Для того, чтобы можно было создать сервер и при этом не использовать vibe.d, мы создали простой класс сервера, который может обслуживать заданный порт по TCP и который сформирован как абстрактный класс:

module simpleserver;

private
{
	import std.algorithm : remove;
	import std.socket;
}

class GenericSimpleServer(uint BUFFER_SIZE, uint MAXIMAL_NUMBER_OF_CONNECTIONS)
{
	protected {
		ubyte[BUFFER_SIZE] _buffer;
		
		string 	   _address;
		ushort    _port;
		Socket     _listener;
		Socket[]   _readable;
		SocketSet  _sockets;
	}
	
	abstract ubyte[] handle(ubyte[] request);
	
	final void run()
	{
		while (1)
		{
			serve;
			
			scope(failure) {
				_sockets = null;
				_listener.close;
			}
		}
	}
	
	final void setup4(string address, ushort port, int backlog = 10)
	{
		_address = address;
		_port = port;
		_listener = new Socket(AddressFamily.INET, SocketType.STREAM);

		with (_listener)
		{
			bind(new InternetAddress(_address, _port));
			listen(backlog);
		}
		
		_sockets = new SocketSet(MAXIMAL_NUMBER_OF_CONNECTIONS + 1);
	}
	
	final void setup6(string address, ushort port, int backlog = 10)
	{
		_address = address;
		_port = port;
		_listener = new Socket(AddressFamily.INET6, SocketType.STREAM);

		with (_listener)
		{
			bind(new Internet6Address(_address, _port));
			listen(backlog);
		}
		
		_sockets = new SocketSet(MAXIMAL_NUMBER_OF_CONNECTIONS + 1);
	}
	
	private
	{
		final void serve()
	    {
	        _sockets.add(_listener);
	
	        foreach (socket; _readable)
	        {
	            _sockets.add(socket);
			}
	
	        Socket.select(_sockets, null, null);
	
	        for (ulong i = 0; i < _readable.length; i++)
	        {
	            if (_sockets.isSet(_readable[i]))
	            {                
	                auto realBufferSize = _readable[i].receive(_buffer);
	
					if (realBufferSize != 0)
	                {
						auto data = _buffer[0..realBufferSize];
						
						_readable[i].send(
							handle(data)			
						);
						
						/*
						writefln(`Client: %s`, txt(cast(string) data));
						writefln(`Server: %s`, txt(cast(string) handle(data)));
						*/
	                }
					
	                _readable[i].close;
	                _readable = _readable.remove(i);
	                i--;
	            }
	        }
	
	        if (_sockets.isSet(_listener))
	        {
	            Socket currentSocket = null;
	            
	            scope (failure)
	            {
	                if (currentSocket)
	                {
	                    currentSocket.close;
					}
	            }
	            
	            currentSocket = _listener.accept;
	            
	            if (_readable.length < MAXIMAL_NUMBER_OF_CONNECTIONS)
	            {
	                _readable ~= currentSocket;
	            }
	            else
	            {
	                currentSocket.close;
	            }
	        }
	
	        _sockets.reset;
	        
		}
	}
}

Для того, чтобы реализовать сервер для Gopher нам потребуется осуществить импорт модуля simpleserver (условимся, что данный класс мы положили в файл simpleserver.d), осуществить наследование от класса GenericSimpleServer с указанием размера буфера сервера (в байтах) и максимальное количество обслуживаемых соединений и переопределить метод handle, который перехватывает байтовый поток от клиента и формирует байтовое представление ответа самого сервера.

Прежде чем продвигаться дальше в реализации Gopher-сервера, немного расскажем об использовании GenericSimpleServer. Этот код сформирован на базе кода простого сервера на сокетах от Кристофера Миллера (честно говоря, хотели вставить ссылку, но из-за давности потеряли ссылку на оригинальный код, но не упомянуть автора было бы свинством) и мы его практически не меняли за исключением превращения кода сервера в абстрактный класс. Для того, чтобы удобнее было работать мы добавили методы serve4 и serve6, в которые подаются сетевой адрес и порт и которые служат для настройки сервера на IPv4 и IPv6 соответственно. Метод run служит для запуска бесконечного цикла с прослушиванием соединения, и также как методы serve4 и serve6 является финальным и не подлежит переопределению.

Наличие готовых методов в GenericSimpleServer для запуска и организации сервера развязывает нам руки в плане создания простых серверов, и единственное что нам осталось сделать — это создать класс GopherServer и переопределить в нем метод handle, а также определить конструктор класса, в который мы будем подавать путь до папки, доступ к которой мы будем давать через Gopher.

Сам класс выглядит так:

class GopherServer : GenericSimpleServer!(8_192, 60) 
{
	private
	{
		import std.conv : to;
		import std.file;
		import std.string;
		
		string _path;
    }
	
	this(string path) 
	{
		_path = path;
	}
	
	override ubyte[] handle(ubyte[] request)
	{
		ubyte[] response;
		
		if (request.length >= 1)
		{
			if ((request.length == 1) || (request == [0x0d, 0x0a]))
			{
				response = createMapFromFS(_path, "[" ~ _address ~ "]", _port.to!string);
			}
			else
			{
				auto entry = (cast(string) request).replace("\r", "").replace("\n", "");
				
				if (entry.isDir)
				{
					response = createMapFromFS(entry, "[" ~ _address ~ "]", _port.to!string);
				}
				else
				{
					response ~= cast(ubyte[]) std.file.read(entry);
				}
			}
		}
		else
		{
			response = createResponse(GOPHER_CONTENT.ERROR, "Invalid request", "/error");
		}
		
		return response;
	}
}

Всю основную работу в классе делает метод handle, но что же в нем происходит ?

В метод handle попадает байтовый поток от клиента и если он по длине меньше 1, то это значит, что запрос некорректный и происходит возврат ответа с типом содержимого 3, т.е возвращается ошибка (и это единственный способ указать клиенту на ошибку). Если длина подходящая то нужно проверить два варианта: или запрос содержит пустую строку (т.е получается что запрос оканчивается либо одним символом, либо двумя символами CR и LF. Почему обрабатывается и один символ, если по спецификации там должно быть как минимум два ? да потому, что некоторые клиенты, такие как например bombadillo, понимают только один CR — и это надо учесть, поскольку клиент популярен), или идет запрос на папку/файл.

В первом случае мы отдаем содержимое основной папки (т.е той которая была указана в конструкторе), используя createMapFromFS, в которой адрес обрамляют квадратные скобки (т.к мы будем работать по IPv6, при желании их можно убрать, если есть доменное имя или адрес IPv4) и указана основная папка, а порт и адрес берутся из переменных абстрактного класса GenericSimpleServer.

Во втором случае, когда надо отдать конкретное содержимое, нужно байтовое представление перевести в строку и удалить из нее признаки окончания запроса (т.е символы CR и LF), проверить является ли то, что мы отдаем папкой. И если это действительно папка, то уже повторно вызываем функцию createMapFromFS, но в качестве пути указываем путь из запроса. Если же, вычлененное значение из запроса указывает на наименование файла, то требуется просто отдать байты файла, прочитав их по указанному пути из файла.

И основная процедура будет выглядеть следующим образом:

module gophered;

import simpleserver;

void main()
{
	auto gopher = new GopherServer("<путь до папки без финального / >");

	with (gopher)
	{
		setup6(`адрес Yggdrasil или просто IPv6`, 70);
		run;
	}
}

Запускаем компиляцию командой (сама реализация Gopher находится в файле gophered.d):

dmd gophered.d simpleserver.d -release -O -m64

Испытать Gopher-сервер следующим образом:

sudo ./gophered.d

И для проверки работоспособности нам потребуется клиент, который поддерживает Gopher, например, Lagrange, который помимо Gemini поддерживает также Gopher и Finger:

или текстовый браузер Lynx, который можно установить в любом дистрибутиве Linux, и даже в Windows:

А вообще можно обойтись и без клиента, используя уже знакомый нам netcat:

echo | nc -6 "адрес IPv6 без квадратных скобок" 70

И вот результат на примере кода из статьи для папки с репозиториями GitHub у автора статьи:

Что дальше ?

Дальше можно усовершенствовать предлагаемый сервер, к примеру, многие Gopher-сервера в качестве начальной страницы предлагают вполне себе полновесное меню, которое помимо файлов/папок/ссылок предлагает еще и текстовые данные (к примеру, с типом содержимого i) и которое обычно называют Gopher-картой или по английски Gophermap (или еще встречается написание gophermap). Также Gophermap может быть не только начальной страницей, но и вложен в любой из каталогов и можно сделать генерацию данных, отдаваемых через Gopher из файлов аналогичных Gophermap с уже сформированными полями. Также можно добавить полнотекстовый поиск и иные типы содержимого, даже из каноничных, но все это мы рассматривать не будем и предлагаем данные направления как поле уже для твоих исследований, наш дорогой читатель.

Для чего все это надо ?

Во многом, это нужно тем, кто раньше работал с данным протоколом и хотел бы поддержать его как энтузиаст или же просто сохранить интересный ресурс. Кроме того, сейчас наблюдается новая волна интереса к Gopher (конечно, не в таких масштабах как было раньше) и даже есть целая флогосфера (аналог блога в Gopher, называется флог или по-английски phlog), в которой много всего интересного, и как говорит русская Википедия, есть даже места где без проблем, не опасаясь спама, выкладываются личные данные (что, конечно, достаточно спекулятивно). Помимо этого существует ряд занимательных возможностей, например, gopherfs, которая позволяет монтировать сервера Gopher, как сетевую папку в режиме read-only. Помимо этого, Gopher удобно использовать в пределах локальной сети для обеспечения общего доступа к файлам, если предполагается только их просмотр.

Напоследок, мы просим тех читателей, кто в курсе того, как работает механика Gopher для содержимого с типом 7, дать нам об этом знать в комментариях под этой статьей, а мы заканчиваем эту статью и оставляем набор ссылок на статьи, которые использовались для подготовки этого материала.

Ссылки

Добавить комментарий