- 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 tempfile
import typing
import urllib.parse
# Fail early to avoid crashing with an obscure error
if sys.version_info < (3, 7):
@ -76,8 +77,8 @@ class EchoApp:
def __iter__(self) -> typing.Iterator[bytes]:
self.send_status(STATUS_SUCCESS, "text/plain")
path = self.environ["PATH_INFO"]
yield f"Received path: {path}".encode()
url = self.environ["RAW_URL"]
yield f"Received path: {url}".encode()
class StaticDirectoryApp:
@ -108,7 +109,7 @@ class StaticDirectoryApp:
return build_class
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)))
if filename.is_absolute() or str(filename.name).startswith(".."):
@ -184,9 +185,10 @@ class GeminiRequestHandler:
self.writer: typing.Optional[asyncio.StreamWriter] = None
self.received_timestamp: typing.Optional[datetime.datetime] = 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.mimetype: typing.Optional[str] = None
self.meta: typing.Optional[str] = None
self.response_buffer: typing.Optional[str] = None
self.response_size: int = 0
@ -206,10 +208,24 @@ class GeminiRequestHandler:
self.received_timestamp = datetime.datetime.utcnow()
try:
await self.parse_request()
await self.parse_header()
except Exception:
# 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:
environ = self.build_environ()
@ -220,49 +236,58 @@ class GeminiRequestHandler:
self.write_status(STATUS_CGI_ERROR, str(e))
raise
finally:
await self.flush_status()
self.log_request()
await writer.drain()
await self.close_connection()
def build_environ(self) -> typing.Dict[str, typing.Any]:
"""
Construct a dictionary that will be passed to the application handler.
"""
return {
"SERVER_NAME": self.server.host,
"SERVER_HOST": self.server.host,
"SERVER_PORT": self.server.port,
"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")
request = data.decode()
self.path = request[:-2] # strip the line ending
data = data[:-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.
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
of the response data sent. If the status is something else, the mimetype
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 meta
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.
"""
# TODO: enforce restriction on response meta <= 1024 bytes
self.status = status
self.mimetype = mimetype
self.response_buffer = f"{status}\t{mimetype}\r\n"
self.meta = meta
self.response_buffer = f"{status}\t{meta}\r\n"
async def write_body(self, data: bytes) -> None:
"""
@ -284,6 +309,14 @@ class GeminiRequestHandler:
await self.writer.drain()
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:
"""
Log a gemini request using a format derived from the Common Log Format.
@ -291,9 +324,9 @@ class GeminiRequestHandler:
self.server.log_message(
f"{self.remote_addr} "
f"[{self.received_timestamp:%d/%b/%Y:%H:%M:%S +0000}] "
f'"{self.path}" '
f'"{self.raw_url}" '
f"{self.status} "
f'"{self.mimetype}" '
f'"{self.meta}" '
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",
description="An Experimental Gemini Server",
long_description=long_description(),
py_modules=["jetforce"],
entry_points={"console_scripts": ["jetforce=jetforce:run_server"]},
py_modules=["jetforce", "jetforce_client"],
entry_points={
"console_scripts": [
"jetforce=jetforce:run_server",
"jetforce-client=jetforce_client:run_client",
]
},
python_requires=">=3.7",
keywords="gemini server tcp gopher asyncio",
classifiers=[