2019-08-06 04:21:28 +02:00
|
|
|
#!/usr/bin/env python3.7
|
2019-08-04 19:52:54 +02:00
|
|
|
import argparse
|
|
|
|
import asyncio
|
|
|
|
import datetime
|
2019-08-05 03:42:27 +02:00
|
|
|
import mimetypes
|
2019-08-06 16:35:03 +02:00
|
|
|
import os
|
2019-08-06 04:21:28 +02:00
|
|
|
import pathlib
|
2019-08-04 19:52:54 +02:00
|
|
|
import ssl
|
2019-08-06 04:49:48 +02:00
|
|
|
import subprocess
|
2019-08-04 19:52:54 +02:00
|
|
|
import sys
|
2019-08-06 04:49:48 +02:00
|
|
|
import tempfile
|
2019-08-06 00:47:59 +02:00
|
|
|
import typing
|
2019-08-04 19:52:54 +02:00
|
|
|
|
2019-08-06 04:21:28 +02:00
|
|
|
# Fail early to avoid crashing with an obscure error
|
|
|
|
if sys.version_info < (3, 7):
|
|
|
|
sys.exit("Fatal Error: jetforce requires Python 3.7+")
|
|
|
|
|
2019-08-09 03:34:29 +02:00
|
|
|
__version__ = "0.0.4"
|
2019-08-04 19:52:54 +02:00
|
|
|
__title__ = "Jetforce Gemini Server"
|
|
|
|
__author__ = "Michael Lazar"
|
|
|
|
__license__ = "GNU General Public License v3.0"
|
|
|
|
__copyright__ = "(c) 2019 Michael Lazar"
|
|
|
|
|
|
|
|
ABOUT = fr"""
|
2019-08-05 03:42:27 +02:00
|
|
|
You are now riding on...
|
2019-08-04 19:52:54 +02:00
|
|
|
_________ _____________
|
|
|
|
______ /______ /___ __/_______________________
|
|
|
|
___ _ /_ _ \ __/_ /_ _ __ \_ ___/ ___/ _ \
|
|
|
|
/ /_/ / / __/ /_ _ __/ / /_/ / / / /__ / __/
|
|
|
|
\____/ \___/\__/ /_/ \____//_/ \___/ \___/
|
|
|
|
|
2019-08-05 03:42:27 +02:00
|
|
|
An Experimental Gemini Server, v{__version__}
|
2019-08-04 19:52:54 +02:00
|
|
|
https://github.com/michael-lazar/jetforce
|
|
|
|
"""
|
|
|
|
|
2019-08-06 05:15:15 +02:00
|
|
|
EPILOG = """
|
|
|
|
If the TLS cert/keyfile is not provided, a self-signed certificate will
|
|
|
|
automatically be generated and saved to your temporary file directory.
|
|
|
|
"""
|
|
|
|
|
2019-08-04 19:52:54 +02:00
|
|
|
# Gemini response status codes
|
2019-08-09 03:34:06 +02:00
|
|
|
STATUS_INPUT_REQUIRED = 10
|
|
|
|
STATUS_SUCCESS = 20
|
|
|
|
STATUS_REDIRECT = 30
|
|
|
|
STATUS_NOT_FOUND = 40
|
|
|
|
STATUS_SERVER_ERROR = 50
|
|
|
|
STATUS_CERTIFICATE_ERROR = 60
|
2019-08-04 19:52:54 +02:00
|
|
|
|
|
|
|
|
|
|
|
class EchoApp:
|
|
|
|
"""
|
2019-08-05 03:42:27 +02:00
|
|
|
A simple application that echos back the requested path.
|
2019-08-04 19:52:54 +02:00
|
|
|
"""
|
|
|
|
|
2019-08-06 00:47:59 +02:00
|
|
|
def __init__(self, environ: dict, send_status: typing.Callable) -> None:
|
2019-08-04 19:52:54 +02:00
|
|
|
self.environ = environ
|
|
|
|
self.send_status = send_status
|
|
|
|
|
2019-08-06 00:47:59 +02:00
|
|
|
def __iter__(self) -> typing.Iterator[bytes]:
|
2019-08-04 19:52:54 +02:00
|
|
|
self.send_status(STATUS_SUCCESS, "text/plain")
|
2019-08-06 15:55:26 +02:00
|
|
|
path = self.environ["PATH_INFO"]
|
2019-08-04 19:52:54 +02:00
|
|
|
yield f"Received path: {path}".encode()
|
|
|
|
|
|
|
|
|
2019-08-05 03:42:27 +02:00
|
|
|
class StaticDirectoryApp:
|
|
|
|
"""
|
|
|
|
Serve a static directory over Gemini.
|
|
|
|
|
|
|
|
If a directory contains a hidden file with the name ".gemini", that file
|
|
|
|
will be returned when the directory path is requested. Otherwise, a
|
|
|
|
directory listing will be auto-generated.
|
|
|
|
"""
|
|
|
|
|
2019-08-06 04:21:28 +02:00
|
|
|
def __init__(self, root: str, environ: dict, send_status: typing.Callable) -> None:
|
|
|
|
self.root = pathlib.Path(root).resolve(strict=True)
|
2019-08-05 03:42:27 +02:00
|
|
|
self.environ = environ
|
|
|
|
self.send_status = send_status
|
|
|
|
|
|
|
|
self.mimetypes = mimetypes.MimeTypes()
|
|
|
|
|
2019-08-06 04:21:28 +02:00
|
|
|
@classmethod
|
|
|
|
def serve_directory(cls, root: str) -> typing.Callable:
|
|
|
|
"""
|
|
|
|
Return an app that points to the given root directory on the file system.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def build_class(environ: dict, send_status: typing.Callable):
|
|
|
|
return cls(root, environ, send_status)
|
|
|
|
|
|
|
|
return build_class
|
|
|
|
|
2019-08-06 00:47:59 +02:00
|
|
|
def __iter__(self) -> typing.Iterator[bytes]:
|
2019-08-06 15:55:26 +02:00
|
|
|
url_path = pathlib.Path(self.environ["PATH_INFO"].strip("/"))
|
2019-08-05 03:42:27 +02:00
|
|
|
|
2019-08-06 16:35:03 +02:00
|
|
|
filename = pathlib.Path(os.path.normpath(str(url_path)))
|
2019-08-07 05:47:11 +02:00
|
|
|
if filename.is_absolute() or str(filename.name).startswith(".."):
|
2019-08-05 03:42:27 +02:00
|
|
|
# Guard against breaking out of the directory
|
|
|
|
self.send_status(STATUS_NOT_FOUND, "Not Found")
|
2019-08-06 04:21:28 +02:00
|
|
|
return
|
2019-08-06 16:35:03 +02:00
|
|
|
else:
|
|
|
|
filesystem_path = self.root / filename
|
2019-08-05 03:42:27 +02:00
|
|
|
|
2019-08-06 04:21:28 +02:00
|
|
|
if filesystem_path.is_file():
|
|
|
|
mimetype = self.guess_mimetype(filesystem_path.name)
|
|
|
|
yield from self.load_file(filesystem_path, mimetype)
|
2019-08-05 03:42:27 +02:00
|
|
|
|
2019-08-06 04:21:28 +02:00
|
|
|
elif filesystem_path.is_dir():
|
|
|
|
gemini_file = filesystem_path / ".gemini"
|
|
|
|
if gemini_file.exists():
|
|
|
|
yield from self.load_file(gemini_file, "text/gemini")
|
2019-08-05 03:42:27 +02:00
|
|
|
else:
|
2019-08-06 04:21:28 +02:00
|
|
|
yield from self.list_directory(url_path, filesystem_path)
|
2019-08-05 03:42:27 +02:00
|
|
|
|
|
|
|
else:
|
|
|
|
self.send_status(STATUS_NOT_FOUND, "Not Found")
|
|
|
|
|
2019-08-06 04:21:28 +02:00
|
|
|
def load_file(self, filesystem_path: pathlib.Path, mimetype: str):
|
2019-08-05 03:42:27 +02:00
|
|
|
self.send_status(STATUS_SUCCESS, mimetype)
|
2019-08-06 04:21:28 +02:00
|
|
|
with filesystem_path.open("rb") as fp:
|
2019-08-05 03:42:27 +02:00
|
|
|
data = fp.read(1024)
|
|
|
|
while data:
|
|
|
|
yield data
|
|
|
|
data = fp.read(1024)
|
|
|
|
|
2019-08-06 04:21:28 +02:00
|
|
|
def list_directory(self, url_path: pathlib.Path, filesystem_path: pathlib.Path):
|
2019-08-05 03:42:27 +02:00
|
|
|
self.send_status(STATUS_SUCCESS, "text/gemini")
|
|
|
|
|
2019-08-06 04:21:28 +02:00
|
|
|
yield f"Directory: /{url_path}\r\n".encode()
|
|
|
|
if url_path.parent != url_path:
|
|
|
|
yield f"=>/{url_path.parent}\t..\r\n".encode()
|
|
|
|
|
|
|
|
for file in sorted(filesystem_path.iterdir()):
|
|
|
|
if file.name.startswith((".", "~")):
|
|
|
|
# Skip hidden and temporary files for security reasons
|
2019-08-05 03:42:27 +02:00
|
|
|
continue
|
2019-08-06 04:21:28 +02:00
|
|
|
elif file.is_dir():
|
|
|
|
yield f"=>/{url_path / file.name}\t{file.name}/\r\n".encode()
|
|
|
|
else:
|
|
|
|
yield f"=>/{url_path / file.name}\t{file.name}\r\n".encode()
|
2019-08-05 03:42:27 +02:00
|
|
|
|
|
|
|
def guess_mimetype(self, filename: str):
|
|
|
|
mime, encoding = self.mimetypes.guess_type(filename)
|
|
|
|
if encoding:
|
|
|
|
return f"{mime}; charset={encoding}"
|
|
|
|
elif mime:
|
|
|
|
return mime
|
|
|
|
else:
|
|
|
|
return "text/plain"
|
|
|
|
|
|
|
|
|
2019-08-04 19:52:54 +02:00
|
|
|
class GeminiRequestHandler:
|
|
|
|
"""
|
|
|
|
Handle a single Gemini Protocol TCP request.
|
|
|
|
|
|
|
|
This design borrows heavily from the standard library's HTTP request
|
|
|
|
handler (http.server.BaseHTTPRequestHandler). However, I did not make any
|
|
|
|
attempts to directly emulate the existing conventions, because Gemini is an
|
|
|
|
inherently simpler protocol than HTTP and much of the boilerplate could be
|
|
|
|
removed or slimmed-down.
|
|
|
|
"""
|
|
|
|
|
2019-08-06 00:47:59 +02:00
|
|
|
def __init__(self, server: "GeminiServer", app: typing.Callable) -> None:
|
2019-08-04 19:52:54 +02:00
|
|
|
self.server = server
|
|
|
|
self.app = app
|
2019-08-06 00:47:59 +02:00
|
|
|
self.reader: typing.Optional[asyncio.StreamReader] = None
|
|
|
|
self.writer: typing.Optional[asyncio.StreamWriter] = None
|
|
|
|
self.received_timestamp: typing.Optional[datetime.datetime] = None
|
2019-08-06 15:55:26 +02:00
|
|
|
self.remote_addr: typing.Optional[str] = None
|
2019-08-06 00:47:59 +02:00
|
|
|
self.path: typing.Optional[str] = None
|
|
|
|
self.status: typing.Optional[int] = None
|
|
|
|
self.mimetype: typing.Optional[str] = None
|
|
|
|
self.response_buffer: typing.Optional[str] = None
|
2019-08-04 19:52:54 +02:00
|
|
|
self.response_size: int = 0
|
|
|
|
|
|
|
|
async def handle(
|
|
|
|
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
|
|
) -> None:
|
|
|
|
"""
|
|
|
|
Main method for the request handler, performs the following:
|
|
|
|
|
|
|
|
1. Read the request bytes from the reader stream
|
|
|
|
2. Parse the request and generate response data
|
|
|
|
3. Write the response bytes to the writer stream
|
|
|
|
"""
|
|
|
|
self.reader = reader
|
|
|
|
self.writer = writer
|
2019-08-06 15:55:26 +02:00
|
|
|
self.remote_addr = writer.get_extra_info("peername")[0]
|
2019-08-04 19:52:54 +02:00
|
|
|
self.received_timestamp = datetime.datetime.utcnow()
|
|
|
|
|
|
|
|
try:
|
|
|
|
await self.parse_request()
|
|
|
|
except Exception:
|
|
|
|
# Malformed request, throw it away and exit immediately
|
|
|
|
return
|
|
|
|
|
|
|
|
try:
|
|
|
|
environ = self.build_environ()
|
|
|
|
app = self.app(environ, self.write_status)
|
|
|
|
for data in app:
|
2019-08-05 03:42:27 +02:00
|
|
|
await self.write_body(data)
|
2019-08-04 19:52:54 +02:00
|
|
|
except Exception as e:
|
|
|
|
self.write_status(STATUS_SERVER_ERROR, str(e))
|
|
|
|
raise
|
|
|
|
finally:
|
2019-08-05 03:42:27 +02:00
|
|
|
await self.flush_status()
|
2019-08-04 19:52:54 +02:00
|
|
|
self.log_request()
|
|
|
|
await writer.drain()
|
|
|
|
|
2019-08-06 00:47:59 +02:00
|
|
|
def build_environ(self) -> typing.Dict[str, typing.Any]:
|
2019-08-04 19:52:54 +02:00
|
|
|
"""
|
|
|
|
Construct a dictionary that will be passed to the application handler.
|
|
|
|
"""
|
|
|
|
return {
|
2019-08-06 15:55:26 +02:00
|
|
|
"SERVER_NAME": self.server.host,
|
2019-08-04 19:52:54 +02:00
|
|
|
"SERVER_PORT": self.server.port,
|
2019-08-06 15:55:26 +02:00
|
|
|
"REMOTE_ADDR": self.remote_addr,
|
|
|
|
"PATH_INFO": self.path,
|
2019-08-04 19:52:54 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
async def parse_request(self) -> None:
|
|
|
|
"""
|
|
|
|
Parse the gemini request line.
|
|
|
|
|
|
|
|
The request is a single UTF-8 line formatted as: <path>\r\n
|
|
|
|
"""
|
|
|
|
data = await self.reader.readuntil(b"\r\n")
|
|
|
|
request = data.decode()
|
|
|
|
self.path = request[:-2] # strip the line ending
|
|
|
|
|
|
|
|
def write_status(self, status: int, mimetype: str) -> None:
|
|
|
|
"""
|
|
|
|
Write the gemini status line to an internal buffer.
|
|
|
|
|
|
|
|
The status line is a single UTF-8 line formatted as:
|
|
|
|
<code>\t<mimetype>\r\n
|
|
|
|
|
|
|
|
If the response status is 2, the mimetype field will contain the type
|
|
|
|
of the response data sent. If the status is something else, the mimetype
|
|
|
|
will contain a descriptive message.
|
|
|
|
|
|
|
|
The status is not written immediately, it's added to an internal buffer
|
|
|
|
that must be flushed. This is done so that the status can be updated as
|
|
|
|
long as no other data has been written to the stream yet.
|
|
|
|
"""
|
|
|
|
self.status = status
|
|
|
|
self.mimetype = mimetype
|
|
|
|
self.response_buffer = f"{status}\t{mimetype}\r\n"
|
|
|
|
|
2019-08-05 03:42:27 +02:00
|
|
|
async def write_body(self, data: bytes) -> None:
|
2019-08-04 19:52:54 +02:00
|
|
|
"""
|
|
|
|
Write bytes to the gemini response body.
|
|
|
|
"""
|
2019-08-05 03:42:27 +02:00
|
|
|
await self.flush_status()
|
2019-08-04 19:52:54 +02:00
|
|
|
self.response_size += len(data)
|
|
|
|
self.writer.write(data)
|
2019-08-05 03:42:27 +02:00
|
|
|
await self.writer.drain()
|
2019-08-04 19:52:54 +02:00
|
|
|
|
2019-08-05 03:42:27 +02:00
|
|
|
async def flush_status(self) -> None:
|
2019-08-04 19:52:54 +02:00
|
|
|
"""
|
|
|
|
Flush the status line from the internal buffer to the socket stream.
|
|
|
|
"""
|
|
|
|
if self.response_buffer and not self.response_size:
|
|
|
|
data = self.response_buffer.encode()
|
|
|
|
self.response_size += len(data)
|
|
|
|
self.writer.write(data)
|
2019-08-05 03:42:27 +02:00
|
|
|
await self.writer.drain()
|
2019-08-04 19:52:54 +02:00
|
|
|
self.response_buffer = None
|
|
|
|
|
|
|
|
def log_request(self) -> None:
|
|
|
|
"""
|
|
|
|
Log a gemini request using a format derived from the Common Log Format.
|
|
|
|
"""
|
|
|
|
self.server.log_message(
|
2019-08-06 15:55:26 +02:00
|
|
|
f"{self.remote_addr} "
|
2019-08-04 19:52:54 +02:00
|
|
|
f"[{self.received_timestamp:%d/%b/%Y:%H:%M:%S +0000}] "
|
|
|
|
f'"{self.path}" '
|
|
|
|
f"{self.status} "
|
|
|
|
f'"{self.mimetype}" '
|
|
|
|
f"{self.response_size}"
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class GeminiServer:
|
|
|
|
"""
|
|
|
|
An asynchronous TCP server that understands the Gemini Protocol.
|
|
|
|
"""
|
|
|
|
|
|
|
|
request_handler_class = GeminiRequestHandler
|
|
|
|
|
|
|
|
def __init__(
|
2019-08-06 04:21:28 +02:00
|
|
|
self, host: str, port: int, ssl_context: ssl.SSLContext, app: typing.Callable
|
2019-08-04 19:52:54 +02:00
|
|
|
) -> None:
|
|
|
|
self.host = host
|
|
|
|
self.port = port
|
2019-08-06 04:21:28 +02:00
|
|
|
self.ssl_context = ssl_context
|
2019-08-04 19:52:54 +02:00
|
|
|
self.app = app
|
|
|
|
|
|
|
|
async def run(self) -> None:
|
|
|
|
"""
|
|
|
|
The main asynchronous server loop.
|
|
|
|
"""
|
|
|
|
self.log_message(ABOUT)
|
|
|
|
server = await asyncio.start_server(
|
|
|
|
self.accept_connection, self.host, self.port, ssl=self.ssl_context
|
|
|
|
)
|
|
|
|
|
|
|
|
socket_info = server.sockets[0].getsockname()
|
2019-08-05 03:42:27 +02:00
|
|
|
self.log_message(f"Listening on {socket_info[0]}:{socket_info[1]}")
|
2019-08-04 19:52:54 +02:00
|
|
|
|
|
|
|
async with server:
|
|
|
|
await server.serve_forever()
|
|
|
|
|
|
|
|
async def accept_connection(
|
|
|
|
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
|
|
):
|
|
|
|
"""
|
|
|
|
Hook called by the socket server when a new connection is accepted.
|
|
|
|
"""
|
|
|
|
request_handler = self.request_handler_class(self, self.app)
|
|
|
|
try:
|
|
|
|
await request_handler.handle(reader, writer)
|
|
|
|
finally:
|
|
|
|
writer.close()
|
|
|
|
|
|
|
|
def log_message(self, message: str):
|
|
|
|
"""
|
|
|
|
Log a diagnostic server message.
|
|
|
|
"""
|
|
|
|
print(message, file=sys.stderr)
|
|
|
|
|
|
|
|
|
2019-08-06 04:49:48 +02:00
|
|
|
def generate_tls_certificate(hostname: str) -> typing.Tuple[str, str]:
|
|
|
|
"""
|
|
|
|
Utility function to generate a self-signed SSL certificate key pair if
|
|
|
|
one isn't provided. This should only be used for development, know what
|
|
|
|
you're doing if you plan to make your server public!
|
|
|
|
"""
|
|
|
|
certfile = pathlib.Path(tempfile.gettempdir()) / f"{hostname}.crt"
|
|
|
|
keyfile = pathlib.Path(tempfile.gettempdir()) / f"{hostname}.key"
|
|
|
|
if not certfile.exists() or not keyfile.exists():
|
|
|
|
print(f"Writing ad hoc TLS certificate to {certfile}")
|
|
|
|
subprocess.run(
|
|
|
|
[
|
|
|
|
f"openssl req -newkey rsa:2048 -nodes -keyout {keyfile}"
|
|
|
|
f' -nodes -x509 -out {certfile} -subj "/CN={hostname}"'
|
|
|
|
],
|
|
|
|
shell=True,
|
|
|
|
check=True,
|
|
|
|
)
|
|
|
|
return str(certfile), str(keyfile)
|
|
|
|
|
|
|
|
|
2019-08-06 15:55:26 +02:00
|
|
|
def run_server() -> None:
|
|
|
|
"""
|
|
|
|
Entry point for running the command line directory server.
|
|
|
|
"""
|
2019-08-04 20:11:22 +02:00
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
prog="jetforce",
|
|
|
|
description="An Experimental Gemini Protocol Server",
|
2019-08-06 05:15:15 +02:00
|
|
|
epilog=EPILOG,
|
2019-08-04 20:11:22 +02:00
|
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
|
|
)
|
2019-08-05 04:01:10 +02:00
|
|
|
parser.add_argument("--host", help="server host", default="127.0.0.1")
|
|
|
|
parser.add_argument("--port", help="server port", type=int, default=1965)
|
2019-08-06 04:49:48 +02:00
|
|
|
parser.add_argument("--tls-certfile", help="TLS certificate file", metavar="FILE")
|
|
|
|
parser.add_argument("--tls-keyfile", help="TLS private key file", metavar="FILE")
|
2019-08-06 15:55:26 +02:00
|
|
|
parser.add_argument("--dir", help="local directory to serve", default="/var/gemini")
|
2019-08-04 19:52:54 +02:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
2019-08-06 04:49:48 +02:00
|
|
|
certfile, keyfile = args.tls_certfile, args.tls_keyfile
|
|
|
|
if not certfile:
|
|
|
|
certfile, keyfile = generate_tls_certificate("localhost")
|
|
|
|
|
2019-08-06 04:21:28 +02:00
|
|
|
ssl_context = ssl.SSLContext()
|
2019-08-06 04:49:48 +02:00
|
|
|
ssl_context.load_cert_chain(certfile, keyfile)
|
2019-08-05 03:42:27 +02:00
|
|
|
|
2019-08-04 19:52:54 +02:00
|
|
|
server = GeminiServer(
|
|
|
|
host=args.host,
|
|
|
|
port=args.port,
|
2019-08-06 04:21:28 +02:00
|
|
|
ssl_context=ssl_context,
|
|
|
|
app=StaticDirectoryApp.serve_directory(args.dir),
|
2019-08-04 19:52:54 +02:00
|
|
|
)
|
|
|
|
asyncio.run(server.run())
|
2019-08-04 20:11:22 +02:00
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
run_server()
|