- Update server to accept a URL instead of a PATH

- Update server to discard any proxy requests
- Add test client
This commit is contained in:
Michael Lazar 2019-08-12 11:24:42 -04:00
parent 224c98966e
commit 0b0c68ee9c
3 changed files with 114 additions and 27 deletions

View File

@ -10,6 +10,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import typing import typing
import urllib.parse
# Fail early to avoid crashing with an obscure error # Fail early to avoid crashing with an obscure error
if sys.version_info < (3, 7): if sys.version_info < (3, 7):
@ -76,8 +77,8 @@ class EchoApp:
def __iter__(self) -> typing.Iterator[bytes]: def __iter__(self) -> typing.Iterator[bytes]:
self.send_status(STATUS_SUCCESS, "text/plain") self.send_status(STATUS_SUCCESS, "text/plain")
path = self.environ["PATH_INFO"] url = self.environ["RAW_URL"]
yield f"Received path: {path}".encode() yield f"Received path: {url}".encode()
class StaticDirectoryApp: class StaticDirectoryApp:
@ -108,7 +109,7 @@ class StaticDirectoryApp:
return build_class return build_class
def __iter__(self) -> typing.Iterator[bytes]: def __iter__(self) -> typing.Iterator[bytes]:
url_path = pathlib.Path(self.environ["PATH_INFO"].strip("/")) url_path = pathlib.Path(self.environ["URL"].path.strip("/"))
filename = pathlib.Path(os.path.normpath(str(url_path))) filename = pathlib.Path(os.path.normpath(str(url_path)))
if filename.is_absolute() or str(filename.name).startswith(".."): if filename.is_absolute() or str(filename.name).startswith(".."):
@ -184,9 +185,10 @@ class GeminiRequestHandler:
self.writer: typing.Optional[asyncio.StreamWriter] = None self.writer: typing.Optional[asyncio.StreamWriter] = None
self.received_timestamp: typing.Optional[datetime.datetime] = None self.received_timestamp: typing.Optional[datetime.datetime] = None
self.remote_addr: typing.Optional[str] = None self.remote_addr: typing.Optional[str] = None
self.path: typing.Optional[str] = None self.raw_url: typing.Optional[str] = None
self.url: typing.Optional[urllib.parse.ParseResult] = None
self.status: typing.Optional[int] = None self.status: typing.Optional[int] = None
self.mimetype: typing.Optional[str] = None self.meta: typing.Optional[str] = None
self.response_buffer: typing.Optional[str] = None self.response_buffer: typing.Optional[str] = None
self.response_size: int = 0 self.response_size: int = 0
@ -206,10 +208,24 @@ class GeminiRequestHandler:
self.received_timestamp = datetime.datetime.utcnow() self.received_timestamp = datetime.datetime.utcnow()
try: try:
await self.parse_request() await self.parse_header()
except Exception: except Exception:
# Malformed request, throw it away and exit immediately # Malformed request, throw it away and exit immediately
return self.write_status(STATUS_BAD_REQUEST, "Could not understand request line")
return await self.close_connection()
# Discard proxy requests, may revisit this in a later version
if self.url.scheme and self.url.scheme != "gemini":
self.write_status(
STATUS_PROXY_REQUEST_REFUSED, 'URL scheme must be "gemini://"'
)
return await self.close_connection()
elif self.url.hostname and self.url.hostname != self.server.hostname:
self.write_status(
STATUS_PROXY_REQUEST_REFUSED,
f'URL hostname must be "{self.server.hostname}"',
)
return await self.close_connection()
try: try:
environ = self.build_environ() environ = self.build_environ()
@ -220,49 +236,58 @@ class GeminiRequestHandler:
self.write_status(STATUS_CGI_ERROR, str(e)) self.write_status(STATUS_CGI_ERROR, str(e))
raise raise
finally: finally:
await self.flush_status() await self.close_connection()
self.log_request()
await writer.drain()
def build_environ(self) -> typing.Dict[str, typing.Any]: def build_environ(self) -> typing.Dict[str, typing.Any]:
""" """
Construct a dictionary that will be passed to the application handler. Construct a dictionary that will be passed to the application handler.
""" """
return { return {
"SERVER_NAME": self.server.host, "SERVER_HOST": self.server.host,
"SERVER_PORT": self.server.port, "SERVER_PORT": self.server.port,
"REMOTE_ADDR": self.remote_addr, "REMOTE_ADDR": self.remote_addr,
"PATH_INFO": self.path, "HOSTNAME": self.server.hostname,
"RAW_URL": self.raw_url,
"URL": self.url,
} }
async def parse_request(self) -> None: async def parse_header(self) -> None:
""" """
Parse the gemini request line. Parse the gemini header line.
The request is a single UTF-8 line formatted as: <path>\r\n The request is a single UTF-8 line formatted as: <URL>\r\n
""" """
data = await self.reader.readuntil(b"\r\n") data = await self.reader.readuntil(b"\r\n")
request = data.decode() data = data[:-2] # strip the line ending
self.path = request[:-2] # strip the line ending if len(data) > 1024:
raise ValueError("URL exceeds max length of 1024 bytes")
def write_status(self, status: int, mimetype: str) -> None: self.raw_url = data.decode()
self.url = urllib.parse.urlparse(self.raw_url)
if not self.url.netloc:
# URL does not contain a scheme and was not prefixed with // per RFC 1808
# TODO: Suggest spec should enforce // when scheme is omitted
self.url = urllib.parse.urlparse(f"//{self.raw_url}")
def write_status(self, status: int, meta: str) -> None:
""" """
Write the gemini status line to an internal buffer. Write the gemini status line to an internal buffer.
The status line is a single UTF-8 line formatted as: The status line is a single UTF-8 line formatted as:
<code>\t<mimetype>\r\n <code>\t<meta>\r\n
If the response status is 2, the mimetype field will contain the type If the response status is 2, the meta field will contain the mimetype
of the response data sent. If the status is something else, the mimetype of the response data sent. If the status is something else, the meta
will contain a descriptive message. will contain a descriptive message.
The status is not written immediately, it's added to an internal buffer 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 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. long as no other data has been written to the stream yet.
""" """
# TODO: enforce restriction on response meta <= 1024 bytes
self.status = status self.status = status
self.mimetype = mimetype self.meta = meta
self.response_buffer = f"{status}\t{mimetype}\r\n" self.response_buffer = f"{status}\t{meta}\r\n"
async def write_body(self, data: bytes) -> None: async def write_body(self, data: bytes) -> None:
""" """
@ -284,6 +309,14 @@ class GeminiRequestHandler:
await self.writer.drain() await self.writer.drain()
self.response_buffer = None self.response_buffer = None
async def close_connection(self) -> None:
"""
Flush any remaining bytes and close the stream.
"""
await self.flush_status()
self.log_request()
await self.writer.drain()
def log_request(self) -> None: def log_request(self) -> None:
""" """
Log a gemini request using a format derived from the Common Log Format. Log a gemini request using a format derived from the Common Log Format.
@ -291,9 +324,9 @@ class GeminiRequestHandler:
self.server.log_message( self.server.log_message(
f"{self.remote_addr} " f"{self.remote_addr} "
f"[{self.received_timestamp:%d/%b/%Y:%H:%M:%S +0000}] " f"[{self.received_timestamp:%d/%b/%Y:%H:%M:%S +0000}] "
f'"{self.path}" ' f'"{self.raw_url}" '
f"{self.status} " f"{self.status} "
f'"{self.mimetype}" ' f'"{self.meta}" '
f"{self.response_size}" f"{self.response_size}"
) )

49
jetforce_client.py Executable file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3.7
"""
A dead-simple gemini client intended to be used for server development and testing.
./jetforce-client
"""
import argparse
import socket
import ssl
import urllib.parse
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
def fetch(url: str, host: str = None, port: str = None):
parsed_url = urllib.parse.urlparse(url)
if not parsed_url.scheme:
parsed_url = urllib.parse.urlparse(f"gemini://{url}")
host = host or parsed_url.hostname
port = port or parsed_url.port or 1965
with socket.create_connection((host, port)) as sock:
with context.wrap_socket(sock) as ssock:
ssock.sendall((url + "\r\n").encode())
fp = ssock.makefile("rb")
header = fp.readline().decode()
print(header)
body = fp.read().decode()
print(body)
def run_client():
parser = argparse.ArgumentParser(description="A simple gemini client")
parser.add_argument("url")
parser.add_argument(
"--host", help="Optional server to connect to, will default to the URL"
)
parser.add_argument(
"--port", help="Optional port to connect to, will default to the URL"
)
args = parser.parse_args()
fetch(args.url, args.host, args.port)
if __name__ == "__main__":
run_client()

View File

@ -17,8 +17,13 @@ setuptools.setup(
author_email="lazar.michael22@gmail.com", author_email="lazar.michael22@gmail.com",
description="An Experimental Gemini Server", description="An Experimental Gemini Server",
long_description=long_description(), long_description=long_description(),
py_modules=["jetforce"], py_modules=["jetforce", "jetforce_client"],
entry_points={"console_scripts": ["jetforce=jetforce:run_server"]}, entry_points={
"console_scripts": [
"jetforce=jetforce:run_server",
"jetforce-client=jetforce_client:run_client",
]
},
python_requires=">=3.7", python_requires=">=3.7",
keywords="gemini server tcp gopher asyncio", keywords="gemini server tcp gopher asyncio",
classifiers=[ classifiers=[